diff --git a/.dockerignore b/.dockerignore
index e7a6de43..4f976f2d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,52 +1,28 @@
-.editorconfig
-.npmrc
-.nvmrc
-
+.git
.github
-docs
-
-**/node_modules
-**/.next
-**/dist
-**.md
-
-# Dependencies
-.pnp
-.pnp.js
-
-# Local env files
-.env
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
-# Testing
-coverage
-
-# Turbo
.turbo
+node_modules
+**/node_modules
-# Vercel
-.vercel
-
-# Build Outputs
-out/
-/build
-**/dist
-
-# Debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
+target
+target-*
+apps/web/.next
+apps/web/out
+**/.next
+**/.turbo
+apps/android
+apps/windows
+artifacts
-**/prisma/music.db
+docs
+scripts
+tests
-# Misc
-.DS_Store
-*.pem
+**/bin
+**/obj
+**/__pycache__
-# Cargo
-**/target
-**/*.log
-.git
\ No newline at end of file
+.env
+*.log
+*.db
diff --git a/.env.example b/.env.example
index f4f9edd7..76940a29 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,22 @@
-JWT_SECRET=
-NEXT_PUBLIC_AI_URL=
\ No newline at end of file
+# Parson needs no secrets or settings for a standard installation.
+# Uncomment only the values your deployment needs.
+# PARSON_PORT=1993
+# PARSON_LIBRARY_NAME=Parson Library
+# PARSON_DATA_DIR=/Parson
+# PARSON_PUBLIC_URL=https://parson.dev
+# PARSON_DATABASE_SNAPSHOTS=true
+# PARSON_DATABASE_SNAPSHOT_GENERATIONS=3
+# PARSON_DATABASE_INTEGRITY_INTERVAL_HOURS=168
+# PARSON_TRANSCODE_CACHE_MAX_BYTES=2147483648
+# Filesystem discovery defaults are tuned for local SSD/NVMe libraries.
+# PARSON_SCAN_THREADS=8
+# PARSON_SCAN_INITIAL_CAPACITY=4096
+# PARSON_SCAN_SKIP_HIDDEN=true
+# PARSON_SCAN_SKIP_SYSTEM=true
+# Semicolon- or comma-separated glob patterns, relative to the library root.
+# PARSON_SCAN_EXCLUDED_DIRS=**/.cache/**;**/@eaDir/**;**/System Volume Information/**
+# Notification queues fall back to a full walk if this many paths accumulate.
+# PARSON_SCAN_MAX_PENDING_CHANGES=4096
+# Periodic repair walk for missed filesystem notifications (0 means every scan).
+# PARSON_SCAN_RECONCILIATION_HOURS=24
+# RUST_LOG=info
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..f4f11226
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+* text=auto eol=lf
+
+*.png binary
+*.ico binary
+*.icns binary
+*.webp binary
+*.woff2 binary
diff --git a/.github/workflows/rust/cargo-check.yml b/.github/workflows/rust/cargo-check.yml
deleted file mode 100644
index c6d5bed2..00000000
--- a/.github/workflows/rust/cargo-check.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: "Check Server"
-
-on:
- push:
- pull_request:
-
-jobs:
- check:
- name: cargo check
- runs-on: ubuntu-latest
- strategy:
- matrix:
- toolchain: [stable, beta, nightly]
- steps:
- - uses: actions/checkout@v4
- - name: Install NASM
- run: sudo apt-get update && sudo apt-get install -y nasm
- - name: Create necessary folders
- run: mkdir -p apps/web/out
- - uses: actions-rust-lang/setup-rust-toolchain@v1
- with:
- toolchain: ${{ matrix.toolchain }}
- components: clippy,rustfmt
- cache: true
- cache-on-failure: true
- matcher: true
- rustflags: ""
- - run: cargo check
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 469bc195..f9791254 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,45 +1,34 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+node_modules/
-# Dependencies
-node_modules
-.pnp
-.pnp.js
-
-# Local env files
-.env
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
-
-# Testing
-coverage
-
-# Turbo
-.turbo
-
-# Vercel
-.vercel
-
-# Build Outputs
.next/
+.turbo/
out/
-build
-dist
-
-# Debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
+dist/
+target/
+target-*/
+artifacts/
+coverage/
+**/bin/
+**/obj/
+**/.source/
+**/next-env.d.ts
+*.tsbuildinfo
+
+apps/mobile/.expo/
+apps/mobile/android/
+apps/mobile/ios/
-# Misc
-.DS_Store
+.env
+.env.*.local
+.vercel/
+*.db
+*.sqlite
+*.sqlite3
*.pem
-# Cargo
-/target
+*.log
+*.lockb
+*.py[cod]
+__pycache__/
-lrclib*
-deprecated
-data.ms
-meilisearch
+.DS_Store
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..a7168be9
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,22 @@
+node_modules/
+**/node_modules/
+
+.next/
+**/.next/
+out/
+dist/
+.turbo/
+target/
+target-*/
+artifacts/
+**/build/
+**/bin/
+**/obj/
+**/.source/
+
+apps/mobile/android/
+apps/mobile/ios/
+
+Cargo.lock
+bun.lock
+LICENSE
diff --git a/Cargo.lock b/Cargo.lock
index f35f1cdc..701dc0b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
-version = 3
+version = 4
[[package]]
name = "actix-codec"
@@ -8,7 +8,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.13.0",
"bytes",
"futures-core",
"futures-sink",
@@ -21,9 +21,9 @@ dependencies = [
[[package]]
name = "actix-cors"
-version = "0.7.0"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331"
+checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
dependencies = [
"actix-utils",
"actix-web",
@@ -34,73 +34,37 @@ dependencies = [
"smallvec",
]
-[[package]]
-name = "actix-embed"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c98712e024dd57b911850c78d6fc5277e8be54afcdef70d6f4b628812c07dcf7"
-dependencies = [
- "actix-web",
- "futures-core",
- "hex",
- "mime_guess",
- "rust-embed 6.8.1",
-]
-
-[[package]]
-name = "actix-files"
-version = "0.6.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be"
-dependencies = [
- "actix-http",
- "actix-service",
- "actix-utils",
- "actix-web",
- "bitflags 2.6.0",
- "bytes",
- "derive_more",
- "futures-core",
- "http-range",
- "log",
- "mime",
- "mime_guess",
- "percent-encoding",
- "pin-project-lite",
- "v_htmlescape",
-]
-
[[package]]
name = "actix-http"
-version = "3.9.0"
+version = "3.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4"
+checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2"
dependencies = [
"actix-codec",
"actix-rt",
"actix-service",
"actix-utils",
- "ahash",
- "base64 0.22.1",
- "bitflags 2.6.0",
+ "base64",
+ "bitflags 2.13.0",
"brotli",
"bytes",
"bytestring",
"derive_more",
"encoding_rs",
"flate2",
+ "foldhash",
"futures-core",
- "h2 0.3.26",
+ "h2",
"http 0.2.12",
"httparse",
"httpdate",
- "itoa 1.0.11",
+ "itoa",
"language-tags",
"local-channel",
"mime",
"percent-encoding",
"pin-project-lite",
- "rand 0.8.5",
+ "rand 0.10.2",
"sha1",
"smallvec",
"tokio",
@@ -116,14 +80,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
dependencies = [
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "actix-multipart"
-version = "0.7.2"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53"
+checksum = "560e3dd4eae03837f86d1b6bf6222c508568eff36845ef5ebb3a0dff480e3f64"
dependencies = [
"actix-multipart-derive",
"actix-utils",
@@ -136,7 +100,7 @@ dependencies = [
"log",
"memchr",
"mime",
- "rand 0.8.5",
+ "rand 0.10.2",
"serde",
"serde_json",
"serde_plain",
@@ -146,22 +110,22 @@ dependencies = [
[[package]]
name = "actix-multipart-derive"
-version = "0.7.0"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b"
+checksum = "8720bceaa6797fd8b2deab968d52e1120b2a8c30950939f6c8cdb42a910bc885"
dependencies = [
- "darling",
- "parse-size",
+ "bytesize",
+ "darling 0.23.0",
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "actix-router"
-version = "0.5.3"
+version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
+checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
dependencies = [
"bytestring",
"cfg-if",
@@ -174,9 +138,9 @@ dependencies = [
[[package]]
name = "actix-rt"
-version = "2.10.0"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208"
+checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63"
dependencies = [
"futures-core",
"tokio",
@@ -184,9 +148,9 @@ dependencies = [
[[package]]
name = "actix-server"
-version = "2.5.0"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894"
+checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502"
dependencies = [
"actix-rt",
"actix-service",
@@ -194,19 +158,18 @@ dependencies = [
"futures-core",
"futures-util",
"mio",
- "socket2",
+ "socket2 0.5.10",
"tokio",
"tracing",
]
[[package]]
name = "actix-service"
-version = "2.0.2"
+version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a"
+checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f"
dependencies = [
"futures-core",
- "paste",
"pin-project-lite",
]
@@ -222,9 +185,9 @@ dependencies = [
[[package]]
name = "actix-web"
-version = "4.9.0"
+version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38"
+checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
dependencies = [
"actix-codec",
"actix-http",
@@ -235,17 +198,17 @@ dependencies = [
"actix-service",
"actix-utils",
"actix-web-codegen",
- "ahash",
"bytes",
"bytestring",
"cfg-if",
"cookie",
"derive_more",
"encoding_rs",
+ "foldhash",
"futures-core",
"futures-util",
"impl-more",
- "itoa 1.0.11",
+ "itoa",
"language-tags",
"log",
"mime",
@@ -257,8 +220,9 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
- "socket2",
+ "socket2 0.6.4",
"time",
+ "tracing",
"url",
]
@@ -271,7 +235,7 @@ dependencies = [
"actix-router",
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
@@ -282,92 +246,61 @@ checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3"
dependencies = [
"actix-utils",
"actix-web",
- "base64 0.22.1",
+ "base64",
"futures-core",
"futures-util",
"log",
"pin-project-lite",
]
-[[package]]
-name = "actix-web-rust-embed-responder"
-version = "2.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47ecbc5efd67fac66e7b4f37ad693cbd9d035cf6174afb5d87f183f887e935c6"
-dependencies = [
- "actix-web",
- "base64 0.21.7",
- "brotli",
- "chrono",
- "flate2",
- "futures-core",
- "lazy_static",
- "regex",
- "rust-embed 8.5.0",
- "rust-embed-for-web",
-]
-
[[package]]
name = "actix-ws"
-version = "0.2.5"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "535aec173810be3ca6f25dd5b4d431ae7125d62000aa3cbae1ec739921b02cf3"
+checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f"
dependencies = [
"actix-codec",
"actix-http",
"actix-web",
+ "bytestring",
"futures-core",
+ "futures-sink",
"tokio",
+ "tokio-util",
]
-[[package]]
-name = "addr2line"
-version = "0.22.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
-dependencies = [
- "gimli",
-]
-
-[[package]]
-name = "adler"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
-
[[package]]
name = "adler2"
-version = "2.0.0"
+version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
-name = "ahash"
-version = "0.8.11"
+name = "aho-corasick"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
- "cfg-if",
- "getrandom 0.2.15",
- "once_cell",
- "version_check",
- "zerocopy",
+ "memchr",
]
[[package]]
-name = "aho-corasick"
-version = "1.1.3"
+name = "aligned"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
- "memchr",
+ "as-slice",
]
[[package]]
name = "aligned-vec"
-version = "0.5.0"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
+checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
+dependencies = [
+ "equator",
+]
[[package]]
name = "alloc-no-stdlib"
@@ -377,19 +310,13 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
-version = "0.2.2"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
-[[package]]
-name = "android-tzdata"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
-
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -401,28 +328,15 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.86"
+version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
-
-[[package]]
-name = "app"
-version = "0.1.0"
-dependencies = [
- "serde",
- "serde_json",
- "tauri",
- "tauri-build",
-]
+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
-version = "1.3.2"
+version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110"
-dependencies = [
- "derive_arbitrary",
-]
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arg_enum_proc_macro"
@@ -432,7 +346,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
@@ -443,49 +357,23 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "arrayvec"
-version = "0.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
-
-[[package]]
-name = "async-trait"
-version = "0.1.81"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "atk"
-version = "0.15.1"
+version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd"
-dependencies = [
- "atk-sys",
- "bitflags 1.3.2",
- "glib",
- "libc",
-]
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
-name = "atk-sys"
-version = "0.15.1"
+name = "as-slice"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6"
+checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps 6.2.2",
+ "stable_deref_trait",
]
[[package]]
@@ -495,36 +383,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
-name = "audiotags"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44e797ce0164cf599c71f2c3849b56301d96a3dc033544588e875686b050ed39"
-dependencies = [
- "audiotags-macro",
- "id3",
- "metaflac",
- "mp4ameta",
- "readme-rustdocifier",
- "thiserror",
-]
-
-[[package]]
-name = "audiotags-macro"
-version = "0.2.0"
+name = "autocfg"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8eaa9b2312fc01f7291f3b7b0f52ed08b1c0177c96a2e696ab55695cc4d06889"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
-name = "autocfg"
-version = "1.3.0"
+name = "av-scenechange"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
+dependencies = [
+ "aligned",
+ "anyhow",
+ "arg_enum_proc_macro",
+ "arrayvec",
+ "log",
+ "num-rational",
+ "num-traits",
+ "pastey",
+ "rayon",
+ "thiserror",
+ "v_frame",
+ "y4m",
+]
[[package]]
name = "av1-grain"
-version = "0.2.3"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf"
+checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
@@ -536,39 +424,36 @@ dependencies = [
[[package]]
name = "avif-serialize"
-version = "0.8.1"
+version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "876c75a42f6364451a033496a14c44bffe41f5f4a8236f697391f11024e596d2"
+checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
dependencies = [
"arrayvec",
]
[[package]]
-name = "backtrace"
-version = "0.3.73"
+name = "aws-lc-rs"
+version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
+checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
dependencies = [
- "addr2line",
- "cc",
- "cfg-if",
- "libc",
- "miniz_oxide 0.7.4",
- "object",
- "rustc-demangle",
+ "aws-lc-sys",
+ "untrusted 0.7.1",
+ "zeroize",
]
[[package]]
-name = "base64"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
-
-[[package]]
-name = "base64"
-version = "0.21.7"
+name = "aws-lc-sys"
+version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
[[package]]
name = "base64"
@@ -578,21 +463,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
-version = "1.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
-
-[[package]]
-name = "base85rs"
-version = "0.1.3"
+version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87678d33a2af71f019ed11f52db246ca6c5557edee2cccbe689676d1ad9c6b5a"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bigdecimal"
-version = "0.4.5"
+version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d712318a27c7150326677b321a5fa91b55f6d9034ffd67f20319e147d40cee"
+checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695"
dependencies = [
"autocfg",
"libm",
@@ -601,27 +480,6 @@ dependencies = [
"num-traits",
]
-[[package]]
-name = "bit-set"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
-dependencies = [
- "bit-vec",
-]
-
-[[package]]
-name = "bit-vec"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
-
-[[package]]
-name = "bit_field"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61"
-
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -630,15 +488,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.6.0"
+version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitstream-io"
-version = "2.5.0"
+version = "4.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3dcde5f311c85b8ca30c2e4198d4326bc342c76541590106f5fa4a50946ea499"
+checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
+dependencies = [
+ "no_std_io2",
+]
[[package]]
name = "blake2"
@@ -646,15 +507,9 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
- "digest",
+ "digest 0.10.7",
]
-[[package]]
-name = "block"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
-
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -664,11 +519,20 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "brotli"
-version = "6.0.0"
+version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
+checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -677,9 +541,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
-version = "4.0.1"
+version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362"
+checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -687,31 +551,31 @@ dependencies = [
[[package]]
name = "bstr"
-version = "1.10.0"
+version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c"
+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
dependencies = [
"memchr",
- "serde",
+ "serde_core",
]
[[package]]
name = "built"
-version = "0.7.4"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "236e6289eda5a812bc6b53c3b024039382a2895fbbeef2d748b2931546d392c4"
+checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]]
name = "bumpalo"
-version = "3.16.0"
+version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
-version = "1.16.3"
+version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "102087e286b4677862ea56cf8fc58bb2cdfa8725c40ffb80fe3a008eb7f2fc83"
+checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
[[package]]
name = "byteorder"
@@ -727,156 +591,83 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
-version = "1.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
-
-[[package]]
-name = "bytestring"
-version = "1.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74d80203ea6b29df88012294f62733de21cfeab47f17b41af3a38bc30a03ee72"
-dependencies = [
- "bytes",
-]
-
-[[package]]
-name = "cairo-rs"
-version = "0.15.12"
+version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc"
-dependencies = [
- "bitflags 1.3.2",
- "cairo-sys-rs",
- "glib",
- "libc",
- "thiserror",
-]
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
-name = "cairo-sys-rs"
-version = "0.15.1"
+name = "bytesize"
+version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8"
-dependencies = [
- "glib-sys",
- "libc",
- "system-deps 6.2.2",
-]
+checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e"
[[package]]
-name = "cargo_toml"
-version = "0.15.3"
+name = "bytestring"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838"
+checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9"
dependencies = [
- "serde",
- "toml 0.7.8",
+ "bytes",
]
[[package]]
name = "cc"
-version = "1.1.10"
+version = "1.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292"
+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
dependencies = [
+ "find-msvc-tools",
"jobserver",
"libc",
+ "shlex",
]
[[package]]
-name = "cesu8"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
-
-[[package]]
-name = "cfb"
-version = "0.7.3"
+name = "cfg-if"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
-dependencies = [
- "byteorder",
- "fnv",
- "uuid",
-]
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
-name = "cfg-expr"
-version = "0.9.1"
+name = "cfg_aliases"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7"
-dependencies = [
- "smallvec",
-]
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
-name = "cfg-expr"
-version = "0.15.8"
+name = "chacha20"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
- "smallvec",
- "target-lexicon",
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
]
-[[package]]
-name = "cfg-if"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-
[[package]]
name = "chrono"
-version = "0.4.38"
+version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
- "android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "cocoa"
-version = "0.24.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
-dependencies = [
- "bitflags 1.3.2",
- "block",
- "cocoa-foundation",
- "core-foundation",
- "core-graphics",
- "foreign-types",
- "libc",
- "objc",
+ "windows-link",
]
[[package]]
-name = "cocoa-foundation"
-version = "0.1.2"
+name = "cmake"
+version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
- "bitflags 1.3.2",
- "block",
- "core-foundation",
- "core-graphics-types",
- "libc",
- "objc",
+ "cc",
]
-[[package]]
-name = "color_quant"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
-
[[package]]
name = "combine"
version = "4.6.7"
@@ -888,35 +679,16 @@ dependencies = [
]
[[package]]
-name = "console"
-version = "0.15.8"
+name = "const-oid"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb"
-dependencies = [
- "encode_unicode",
- "lazy_static",
- "libc",
- "unicode-width",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "const-oid"
-version = "0.9.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
-
-[[package]]
-name = "convert_case"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
-version = "0.6.0"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
@@ -934,9 +706,9 @@ dependencies = [
[[package]]
name = "core-foundation"
-version = "0.9.4"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
@@ -949,61 +721,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
-name = "core-graphics"
-version = "0.22.3"
+name = "cpufeatures"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
- "bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
- "foreign-types",
"libc",
]
[[package]]
-name = "core-graphics-types"
-version = "0.1.3"
+name = "cpufeatures"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
- "bitflags 1.3.2",
- "core-foundation",
"libc",
]
[[package]]
-name = "cpufeatures"
-version = "0.2.13"
+name = "crc32fast"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
- "libc",
+ "cfg-if",
]
[[package]]
-name = "crc32fast"
-version = "1.4.2"
+name = "crossbeam"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
+checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
- "cfg-if",
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
]
[[package]]
name = "crossbeam-channel"
-version = "0.5.13"
+version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
-version = "0.8.5"
+version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
+checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
@@ -1011,232 +781,188 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
-version = "0.9.18"
+version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
-name = "crossbeam-utils"
-version = "0.8.20"
+name = "crossbeam-queue"
+version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
+checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
+dependencies = [
+ "crossbeam-utils",
+]
[[package]]
-name = "crunchy"
-version = "0.2.2"
+name = "crossbeam-utils"
+version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
-name = "cssparser"
-version = "0.27.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a"
-dependencies = [
- "cssparser-macros",
- "dtoa-short",
- "itoa 0.4.8",
- "matches",
- "phf 0.8.0",
- "proc-macro2",
- "quote",
- "smallvec",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "cssparser"
-version = "0.31.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be"
-dependencies = [
- "cssparser-macros",
- "dtoa-short",
- "itoa 1.0.11",
- "phf 0.11.2",
- "smallvec",
-]
-
-[[package]]
-name = "cssparser-macros"
-version = "0.6.1"
+name = "crypto-common"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
- "quote",
- "syn 2.0.74",
+ "hybrid-array",
]
[[package]]
-name = "ctor"
-version = "0.2.8"
+name = "darling"
+version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
- "quote",
- "syn 2.0.74",
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
]
[[package]]
-name = "curve25519-dalek"
-version = "4.1.3"
+name = "darling"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
- "cfg-if",
- "cpufeatures",
- "curve25519-dalek-derive",
- "digest",
- "fiat-crypto",
- "rustc_version",
- "subtle",
- "zeroize",
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
]
[[package]]
-name = "curve25519-dalek-derive"
-version = "0.1.1"
+name = "darling_core"
+version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
+ "fnv",
+ "ident_case",
"proc-macro2",
"quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "darling"
-version = "0.20.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
-dependencies = [
- "darling_core",
- "darling_macro",
+ "strsim",
+ "syn",
]
[[package]]
name = "darling_core"
-version = "0.20.10"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
- "fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "darling_macro"
-version = "0.20.10"
+version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
- "darling_core",
+ "darling_core 0.21.3",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
-name = "data-encoding"
-version = "2.6.0"
+name = "darling_macro"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn",
+]
[[package]]
-name = "der"
-version = "0.7.9"
+name = "data-encoding"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
-dependencies = [
- "const-oid",
- "zeroize",
-]
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deranged"
-version = "0.3.11"
+version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
-dependencies = [
- "powerfmt",
- "serde",
-]
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
-name = "derive_arbitrary"
-version = "1.3.2"
+name = "derive_more"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
+ "derive_more-impl",
]
[[package]]
-name = "derive_more"
-version = "0.99.18"
+name = "derive_more-impl"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
- "convert_case 0.4.0",
+ "convert_case",
"proc-macro2",
"quote",
"rustc_version",
- "syn 2.0.74",
+ "syn",
+ "unicode-xid",
]
[[package]]
name = "diesel"
-version = "2.2.2"
+version = "2.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf97ee7261bb708fa3402fa9c17a54b70e90e3cb98afb3dc8999d5512cb03f94"
+checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1"
dependencies = [
"bigdecimal",
"chrono",
"diesel_derives",
+ "downcast-rs",
"libsqlite3-sys",
"num-bigint",
"num-integer",
"num-traits",
"r2d2",
+ "sqlite-wasm-rs",
"time",
]
[[package]]
name = "diesel_derives"
-version = "2.2.2"
+version = "2.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6ff2be1e7312c858b2ef974f5c7089833ae57b5311b334b30923af58e5718d8"
+checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5"
dependencies = [
"diesel_table_macro_syntax",
"dsl_auto_type",
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "diesel_migrations"
-version = "2.2.0"
+version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6"
+checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8"
dependencies = [
"diesel",
"migrations_internals",
@@ -1245,11 +971,11 @@ dependencies = [
[[package]]
name = "diesel_table_macro_syntax"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25"
+checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
dependencies = [
- "syn 2.0.74",
+ "syn",
]
[[package]]
@@ -1258,68 +984,52 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
- "block-buffer",
- "crypto-common",
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
"subtle",
]
[[package]]
-name = "dirs"
-version = "5.0.1"
+name = "digest"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
- "dirs-sys",
+ "block-buffer 0.12.1",
+ "const-oid",
+ "crypto-common 0.2.2",
]
[[package]]
-name = "dirs-next"
-version = "2.0.0"
+name = "dirs"
+version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
- "cfg-if",
- "dirs-sys-next",
+ "dirs-sys",
]
[[package]]
name = "dirs-sys"
-version = "0.4.1"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.48.0",
-]
-
-[[package]]
-name = "dirs-sys-next"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
-dependencies = [
- "libc",
- "redox_users",
- "winapi",
+ "windows-sys 0.61.2",
]
-[[package]]
-name = "dispatch"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
-
[[package]]
name = "displaydoc"
-version = "0.2.5"
+version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
@@ -1328,33 +1038,24 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "downcast-rs"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
+
[[package]]
name = "dsl_auto_type"
-version = "0.1.2"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5d9abe6314103864cc2d8901b7ae224e0ab1a103a0a416661b4097b0779b607"
+checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e"
dependencies = [
- "darling",
+ "darling 0.21.3",
"either",
- "heck 0.5.0",
+ "heck",
"proc-macro2",
"quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "dtoa"
-version = "1.0.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
-
-[[package]]
-name = "dtoa-short"
-version = "0.3.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
-dependencies = [
- "dtoa",
+ "syn",
]
[[package]]
@@ -1363,201 +1064,110 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
-[[package]]
-name = "ed25519"
-version = "2.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
-dependencies = [
- "pkcs8",
- "signature",
-]
-
-[[package]]
-name = "ed25519-dalek"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
-dependencies = [
- "curve25519-dalek",
- "ed25519",
- "serde",
- "sha2",
- "signature",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "ego-tree"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"
-
[[package]]
name = "either"
-version = "1.13.0"
+version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
-dependencies = [
- "serde",
-]
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "embed-resource"
-version = "2.4.3"
+version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4edcacde9351c33139a41e3c97eb2334351a81a2791bebb0b243df837128f602"
+checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
dependencies = [
"cc",
"memchr",
"rustc_version",
- "toml 0.8.19",
+ "toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg",
]
-[[package]]
-name = "embed_plist"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
-
-[[package]]
-name = "encode_unicode"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
-
[[package]]
name = "encoding_rs"
-version = "0.8.34"
+version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
-name = "equivalent"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
-
-[[package]]
-name = "errno"
-version = "0.3.9"
+name = "equator"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
+checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
- "libc",
- "windows-sys 0.52.0",
+ "equator-macro",
]
[[package]]
-name = "exr"
-version = "1.72.0"
+name = "equator-macro"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "887d93f60543e9a9362ef8a21beedd0a833c5d9610e18c67abe15a5963dcb1a4"
+checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
- "bit_field",
- "flume",
- "half",
- "lebe",
- "miniz_oxide 0.7.4",
- "rayon-core",
- "smallvec",
- "zune-inflate",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "fancy-regex"
-version = "0.13.0"
+name = "equivalent"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
-dependencies = [
- "bit-set",
- "regex-automata 0.4.7",
- "regex-syntax 0.8.4",
-]
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
-name = "fastrand"
-version = "1.9.0"
+name = "errno"
+version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
- "instant",
+ "libc",
+ "windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
-version = "2.1.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
-version = "0.3.4"
+version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
-name = "fiat-crypto"
-version = "0.2.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
-
-[[package]]
-name = "field-offset"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
-dependencies = [
- "memoffset",
- "rustc_version",
-]
-
-[[package]]
-name = "filetime"
-version = "0.2.25"
+name = "find-msvc-tools"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
-dependencies = [
- "cfg-if",
- "libc",
- "libredox",
- "windows-sys 0.59.0",
-]
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
-version = "1.0.33"
+version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
- "miniz_oxide 0.8.0",
-]
-
-[[package]]
-name = "fluent-uri"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
-dependencies = [
- "bitflags 1.3.2",
+ "miniz_oxide",
]
[[package]]
name = "flume"
-version = "0.11.0"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
+ "futures-core",
+ "futures-sink",
"spin",
]
@@ -1568,44 +1178,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
-name = "foreign-types"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-dependencies = [
- "foreign-types-shared",
-]
-
-[[package]]
-name = "foreign-types-shared"
-version = "0.1.1"
+name = "foldhash"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
-name = "futf"
-version = "0.1.5"
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
- "mac",
- "new_debug_unreachable",
+ "libc",
]
[[package]]
name = "futures"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
@@ -1618,9 +1224,9 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
@@ -1628,15 +1234,15 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
@@ -1645,38 +1251,38 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "futures-sink"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
-version = "0.3.30"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
@@ -1686,342 +1292,82 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
- "pin-utils",
"slab",
]
[[package]]
-name = "fxhash"
-version = "0.2.1"
+name = "generic-array"
+version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
-dependencies = [
- "byteorder",
-]
-
-[[package]]
-name = "gdk"
-version = "0.15.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8"
-dependencies = [
- "bitflags 1.3.2",
- "cairo-rs",
- "gdk-pixbuf",
- "gdk-sys",
- "gio",
- "glib",
- "libc",
- "pango",
-]
-
-[[package]]
-name = "gdk-pixbuf"
-version = "0.15.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a"
-dependencies = [
- "bitflags 1.3.2",
- "gdk-pixbuf-sys",
- "gio",
- "glib",
- "libc",
-]
-
-[[package]]
-name = "gdk-pixbuf-sys"
-version = "0.15.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7"
-dependencies = [
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps 6.2.2",
-]
-
-[[package]]
-name = "gdk-sys"
-version = "0.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88"
-dependencies = [
- "cairo-sys-rs",
- "gdk-pixbuf-sys",
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "pango-sys",
- "pkg-config",
- "system-deps 6.2.2",
-]
-
-[[package]]
-name = "gdkwayland-sys"
-version = "0.15.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cca49a59ad8cfdf36ef7330fe7bdfbe1d34323220cc16a0de2679ee773aee2c2"
-dependencies = [
- "gdk-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "pkg-config",
- "system-deps 6.2.2",
-]
-
-[[package]]
-name = "gdkx11-sys"
-version = "0.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4b7f8c7a84b407aa9b143877e267e848ff34106578b64d1e0a24bf550716178"
-dependencies = [
- "gdk-sys",
- "glib-sys",
- "libc",
- "system-deps 6.2.2",
- "x11",
-]
-
-[[package]]
-name = "generator"
-version = "0.7.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
-dependencies = [
- "cc",
- "libc",
- "log",
- "rustversion",
- "windows 0.48.0",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.14.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
-name = "getopts"
-version = "0.2.21"
+name = "getrandom"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
- "unicode-width",
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
]
[[package]]
name = "getrandom"
-version = "0.1.16"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "wasi 0.9.0+wasi-snapshot-preview1",
+ "r-efi 5.3.0",
+ "wasip2",
]
[[package]]
name = "getrandom"
-version = "0.2.15"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
- "wasi 0.11.0+wasi-snapshot-preview1",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
"wasm-bindgen",
]
-[[package]]
-name = "gif"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2"
-dependencies = [
- "color_quant",
- "weezl",
-]
-
-[[package]]
-name = "gimli"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
-
-[[package]]
-name = "gio"
-version = "0.15.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b"
-dependencies = [
- "bitflags 1.3.2",
- "futures-channel",
- "futures-core",
- "futures-io",
- "gio-sys",
- "glib",
- "libc",
- "once_cell",
- "thiserror",
-]
-
-[[package]]
-name = "gio-sys"
-version = "0.15.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d"
-dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps 6.2.2",
- "winapi",
-]
-
-[[package]]
-name = "glib"
-version = "0.15.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d"
-dependencies = [
- "bitflags 1.3.2",
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-task",
- "glib-macros",
- "glib-sys",
- "gobject-sys",
- "libc",
- "once_cell",
- "smallvec",
- "thiserror",
-]
-
-[[package]]
-name = "glib-macros"
-version = "0.15.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a"
-dependencies = [
- "anyhow",
- "heck 0.4.1",
- "proc-macro-crate",
- "proc-macro-error",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "glib-sys"
-version = "0.15.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
-dependencies = [
- "libc",
- "system-deps 6.2.2",
-]
-
[[package]]
name = "glob"
-version = "0.3.1"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
-version = "0.4.14"
+version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
+checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
dependencies = [
"aho-corasick",
"bstr",
"log",
- "regex-automata 0.4.7",
- "regex-syntax 0.8.4",
-]
-
-[[package]]
-name = "gobject-sys"
-version = "0.15.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a"
-dependencies = [
- "glib-sys",
- "libc",
- "system-deps 6.2.2",
-]
-
-[[package]]
-name = "gtk"
-version = "0.15.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0"
-dependencies = [
- "atk",
- "bitflags 1.3.2",
- "cairo-rs",
- "field-offset",
- "futures-channel",
- "gdk",
- "gdk-pixbuf",
- "gio",
- "glib",
- "gtk-sys",
- "gtk3-macros",
- "libc",
- "once_cell",
- "pango",
- "pkg-config",
-]
-
-[[package]]
-name = "gtk-sys"
-version = "0.15.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84"
-dependencies = [
- "atk-sys",
- "cairo-sys-rs",
- "gdk-pixbuf-sys",
- "gdk-sys",
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "pango-sys",
- "system-deps 6.2.2",
-]
-
-[[package]]
-name = "gtk3-macros"
-version = "0.15.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d"
-dependencies = [
- "anyhow",
- "proc-macro-crate",
- "proc-macro-error",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
+ "regex-automata",
+ "regex-syntax",
]
[[package]]
name = "h2"
-version = "0.3.26"
+version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"bytes",
"fnv",
@@ -2029,68 +1375,27 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
- "indexmap 2.3.0",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
-[[package]]
-name = "h2"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab"
-dependencies = [
- "atomic-waker",
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "http 1.1.0",
- "indexmap 2.3.0",
+ "indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
-[[package]]
-name = "half"
-version = "2.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888"
-dependencies = [
- "cfg-if",
- "crunchy",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
-
[[package]]
name = "hashbrown"
-version = "0.14.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
-
-[[package]]
-name = "heck"
-version = "0.3.3"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
- "unicode-segmentation",
+ "foldhash",
]
[[package]]
-name = "heck"
-version = "0.4.1"
+name = "hashbrown"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
@@ -2098,46 +1403,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-[[package]]
-name = "hermit-abi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
-
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
-[[package]]
-name = "html5ever"
-version = "0.26.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7"
-dependencies = [
- "log",
- "mac",
- "markup5ever 0.11.0",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "html5ever"
-version = "0.27.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
-dependencies = [
- "log",
- "mac",
- "markup5ever 0.12.1",
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
[[package]]
name = "http"
version = "0.2.12"
@@ -2146,54 +1411,47 @@ checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
- "itoa 1.0.11",
+ "itoa",
]
[[package]]
name = "http"
-version = "1.1.0"
+version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
- "fnv",
- "itoa 1.0.11",
+ "itoa",
]
[[package]]
name = "http-body"
-version = "1.0.1"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
- "http 1.1.0",
+ "http 1.4.2",
]
[[package]]
name = "http-body-util"
-version = "0.1.2"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
- "futures-util",
- "http 1.1.0",
+ "futures-core",
+ "http 1.4.2",
"http-body",
"pin-project-lite",
]
-[[package]]
-name = "http-range"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
-
[[package]]
name = "httparse"
-version = "1.9.4"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
@@ -2201,20 +1459,29 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "hybrid-array"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "hyper"
-version = "1.4.1"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
+ "atomic-waker",
"bytes",
"futures-channel",
- "futures-util",
- "h2 0.4.5",
- "http 1.1.0",
+ "futures-core",
+ "http 1.4.2",
"http-body",
"httparse",
- "itoa 1.0.11",
+ "itoa",
"pin-project-lite",
"smallvec",
"tokio",
@@ -2223,68 +1490,53 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.27.2"
+version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
- "futures-util",
- "http 1.1.0",
+ "http 1.4.2",
"hyper",
"hyper-util",
"rustls",
- "rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots",
]
[[package]]
-name = "hyper-tls"
-version = "0.6.0"
+name = "hyper-util"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64",
"bytes",
- "http-body-util",
+ "futures-channel",
+ "futures-util",
+ "http 1.4.2",
+ "http-body",
"hyper",
- "hyper-util",
- "native-tls",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2 0.6.4",
"tokio",
- "tokio-native-tls",
"tower-service",
+ "tracing",
]
[[package]]
-name = "hyper-util"
-version = "0.1.7"
+name = "iana-time-zone"
+version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9"
-dependencies = [
- "bytes",
- "futures-channel",
- "futures-util",
- "http 1.1.0",
- "http-body",
- "hyper",
- "pin-project-lite",
- "socket2",
- "tokio",
- "tower",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "iana-time-zone"
-version = "0.1.60"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
+ "log",
"wasm-bindgen",
"windows-core",
]
@@ -2299,1387 +1551,956 @@ dependencies = [
]
[[package]]
-name = "ico"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3804960be0bb5e4edb1e1ad67afd321a9ecfd875c3e65c099468fd2717d7cae"
-dependencies = [
- "byteorder",
- "png",
-]
-
-[[package]]
-name = "id3"
-version = "1.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55f4e785f2c700217ee82a1c727c720449421742abd5fcb2f1df04e1244760e9"
-dependencies = [
- "bitflags 2.6.0",
- "byteorder",
- "flate2",
-]
-
-[[package]]
-name = "ident_case"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
-
-[[package]]
-name = "idna"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
-dependencies = [
- "unicode-bidi",
- "unicode-normalization",
-]
-
-[[package]]
-name = "ignore"
-version = "0.4.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1"
-dependencies = [
- "crossbeam-deque",
- "globset",
- "log",
- "memchr",
- "regex-automata 0.4.7",
- "same-file",
- "walkdir",
- "winapi-util",
-]
-
-[[package]]
-name = "image"
-version = "0.24.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
-dependencies = [
- "bytemuck",
- "byteorder",
- "color_quant",
- "num-traits",
-]
-
-[[package]]
-name = "image"
-version = "0.25.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
-dependencies = [
- "bytemuck",
- "byteorder-lite",
- "color_quant",
- "exr",
- "gif",
- "image-webp",
- "num-traits",
- "png",
- "qoi",
- "ravif",
- "rayon",
- "rgb",
- "tiff",
- "zune-core",
- "zune-jpeg",
-]
-
-[[package]]
-name = "image-webp"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904"
-dependencies = [
- "byteorder-lite",
- "quick-error",
-]
-
-[[package]]
-name = "imgref"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126"
-
-[[package]]
-name = "impl-more"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d"
-
-[[package]]
-name = "indexmap"
-version = "1.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
-dependencies = [
- "autocfg",
- "hashbrown 0.12.3",
- "serde",
-]
-
-[[package]]
-name = "indexmap"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0"
-dependencies = [
- "equivalent",
- "hashbrown 0.14.5",
- "serde",
-]
-
-[[package]]
-name = "indicatif"
-version = "0.17.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3"
-dependencies = [
- "console",
- "instant",
- "number_prefix",
- "portable-atomic",
- "unicode-width",
-]
-
-[[package]]
-name = "infer"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc"
-dependencies = [
- "cfb",
-]
-
-[[package]]
-name = "instant"
-version = "0.1.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "interpolate_name"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "ipnet"
-version = "2.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
-
-[[package]]
-name = "iso8601"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153"
-dependencies = [
- "nom",
-]
-
-[[package]]
-name = "itertools"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itoa"
-version = "0.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
-
-[[package]]
-name = "itoa"
-version = "1.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
-
-[[package]]
-name = "javascriptcore-rs"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c"
-dependencies = [
- "bitflags 1.3.2",
- "glib",
- "javascriptcore-rs-sys",
-]
-
-[[package]]
-name = "javascriptcore-rs-sys"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c"
-dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps 5.0.0",
-]
-
-[[package]]
-name = "jni"
-version = "0.20.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c"
-dependencies = [
- "cesu8",
- "combine",
- "jni-sys",
- "log",
- "thiserror",
- "walkdir",
-]
-
-[[package]]
-name = "jni-sys"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
-
-[[package]]
-name = "jobserver"
-version = "0.1.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "jpeg-decoder"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
-
-[[package]]
-name = "js-sys"
-version = "0.3.70"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
-dependencies = [
- "wasm-bindgen",
-]
-
-[[package]]
-name = "json-patch"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc"
-dependencies = [
- "jsonptr",
- "serde",
- "serde_json",
- "thiserror",
-]
-
-[[package]]
-name = "jsonptr"
-version = "0.4.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627"
-dependencies = [
- "fluent-uri",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "jsonwebtoken"
-version = "9.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
-dependencies = [
- "base64 0.21.7",
- "js-sys",
- "pem",
- "ring",
- "serde",
- "serde_json",
- "simple_asn1",
-]
-
-[[package]]
-name = "kuchikiki"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8"
-dependencies = [
- "cssparser 0.27.2",
- "html5ever 0.26.0",
- "indexmap 1.9.3",
- "matches",
- "selectors 0.22.0",
-]
-
-[[package]]
-name = "language-tags"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
-
-[[package]]
-name = "lazy_static"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-
-[[package]]
-name = "lebe"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
-
-[[package]]
-name = "levenshtein"
-version = "1.0.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760"
-
-[[package]]
-name = "libc"
-version = "0.2.155"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
-
-[[package]]
-name = "libfuzzer-sys"
-version = "0.4.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7"
-dependencies = [
- "arbitrary",
- "cc",
- "once_cell",
-]
-
-[[package]]
-name = "libm"
-version = "0.2.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
-
-[[package]]
-name = "libredox"
-version = "0.1.3"
+name = "icu_collections"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
- "bitflags 2.6.0",
- "libc",
- "redox_syscall",
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
]
[[package]]
-name = "libsqlite3-sys"
-version = "0.29.0"
+name = "icu_locale_core"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4588d65215825ee71ebff9e1c9982067833b1355d7546845ffdb3165cbd7456"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
- "cc",
- "pkg-config",
- "vcpkg",
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
]
[[package]]
-name = "libwebp-sys"
-version = "0.9.5"
+name = "icu_normalizer"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "829b6b604f31ed6d2bccbac841fe0788de93dbd87e4eb1ba2c4adfe8c012a838"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
- "cc",
- "glob",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
]
[[package]]
-name = "linux-raw-sys"
-version = "0.4.14"
+name = "icu_normalizer_data"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
-name = "local-channel"
-version = "0.1.5"
+name = "icu_properties"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
- "futures-core",
- "futures-sink",
- "local-waker",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
]
[[package]]
-name = "local-waker"
-version = "0.1.4"
+name = "icu_properties_data"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
-name = "lock_api"
-version = "0.4.12"
+name = "icu_provider"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
- "autocfg",
- "scopeguard",
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
]
[[package]]
-name = "lofty"
-version = "0.18.2"
+name = "id3"
+version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f75066eb1d25a7047fb2667edb410ae2592439ed81546f95c28b0a1c7d7d3818"
+checksum = "70d6d9f23a28c6feab60d7b4a339e004809019ec9d5fd964b3f4c655ff9bc141"
dependencies = [
+ "bitflags 2.13.0",
"byteorder",
- "data-encoding",
"flate2",
- "lofty_attr",
- "log",
- "ogg_pager",
- "paste",
]
[[package]]
-name = "lofty_attr"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "764b60e1ddd07e5665a6a17636a95cd7d8f3b86c73503a69c32979d05f72f3cf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "log"
-version = "0.4.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
-
-[[package]]
-name = "loom"
-version = "0.5.6"
+name = "ident_case"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
-dependencies = [
- "cfg-if",
- "generator",
- "scoped-tls",
- "serde",
- "serde_json",
- "tracing",
- "tracing-subscriber",
-]
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
-name = "loop9"
-version = "0.1.5"
+name = "idna"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
- "imgref",
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
]
[[package]]
-name = "mac"
-version = "0.1.1"
+name = "idna_adapter"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
[[package]]
-name = "malloc_buf"
-version = "0.0.6"
+name = "if-addrs"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+checksum = "bf39cc0423ee66021dc5eccface85580e4a001e0c5288bae8bea7ecb69225e90"
dependencies = [
"libc",
+ "windows-sys 0.59.0",
]
[[package]]
-name = "markup5ever"
-version = "0.11.0"
+name = "image"
+version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
- "log",
- "phf 0.10.1",
- "phf_codegen 0.10.0",
- "string_cache",
- "string_cache_codegen",
- "tendril",
+ "bytemuck",
+ "byteorder-lite",
+ "image-webp",
+ "moxcms",
+ "num-traits",
+ "png",
+ "zune-core",
+ "zune-jpeg",
]
[[package]]
-name = "markup5ever"
-version = "0.12.1"
+name = "image-webp"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
- "log",
- "phf 0.11.2",
- "phf_codegen 0.11.2",
- "string_cache",
- "string_cache_codegen",
- "tendril",
+ "byteorder-lite",
+ "quick-error",
]
[[package]]
-name = "matchers"
-version = "0.1.0"
+name = "imgref"
+version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
-dependencies = [
- "regex-automata 0.1.10",
-]
+checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
[[package]]
-name = "matches"
-version = "0.1.10"
+name = "impl-more"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
+checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
[[package]]
-name = "maybe-rayon"
-version = "0.1.1"
+name = "indexmap"
+version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
- "cfg-if",
- "rayon",
+ "equivalent",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "meilisearch-index-setting-macro"
-version = "0.27.1"
+name = "inotify"
+version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "056e8c0652af81cc6525e0d9c0e1037ea7bcd77955dcd4aef1a1441be7ad7e55"
+checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
dependencies = [
- "convert_case 0.6.0",
- "proc-macro2",
- "quote",
- "structmeta",
- "syn 2.0.74",
+ "bitflags 2.13.0",
+ "inotify-sys",
+ "libc",
]
[[package]]
-name = "meilisearch-sdk"
-version = "0.27.1"
+name = "inotify-sys"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66958255878d712b4f2dece377a8661b41dc976ff15f564b91bfce8b4a619304"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
- "async-trait",
- "bytes",
- "either",
- "futures",
- "futures-io",
- "iso8601",
- "jsonwebtoken",
- "log",
- "meilisearch-index-setting-macro",
- "pin-project-lite",
- "reqwest",
- "serde",
- "serde_json",
- "thiserror",
- "time",
- "uuid",
- "wasm-bindgen-futures",
- "web-sys",
- "yaup",
+ "libc",
]
[[package]]
-name = "memchr"
-version = "2.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
-
-[[package]]
-name = "memoffset"
-version = "0.9.1"
+name = "interpolate_name"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
- "autocfg",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "metaflac"
-version = "0.2.7"
+name = "io-uring"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0f083edae4a21f5acb1fda8220d1c14fa31f725bfd4e21005a14c2d8944db9b"
+checksum = "595a0399f411a508feb2ec1e970a4a30c249351e30208960d58298de8660b0e5"
dependencies = [
- "byteorder",
- "hex",
+ "bitflags 1.3.2",
+ "libc",
]
[[package]]
-name = "migrations_internals"
-version = "2.2.0"
+name = "ipnet"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff"
-dependencies = [
- "serde",
- "toml 0.8.19",
-]
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
-name = "migrations_macros"
-version = "2.2.0"
+name = "itertools"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
- "migrations_internals",
- "proc-macro2",
- "quote",
+ "either",
]
[[package]]
-name = "mime"
-version = "0.3.17"
+name = "itoa"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
-name = "mime_guess"
-version = "2.0.5"
+name = "jni"
+version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
- "mime",
- "unicase",
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror",
+ "walkdir",
+ "windows-link",
]
[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
+name = "jni-macros"
+version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn",
+]
[[package]]
-name = "miniz_oxide"
-version = "0.7.4"
+name = "jni-sys"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
- "adler",
- "simd-adler32",
+ "jni-sys-macros",
]
[[package]]
-name = "miniz_oxide"
-version = "0.8.0"
+name = "jni-sys-macros"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
- "adler2",
+ "quote",
+ "syn",
]
[[package]]
-name = "mio"
-version = "1.0.2"
+name = "jobserver"
+version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
- "hermit-abi",
+ "getrandom 0.4.3",
"libc",
- "log",
- "wasi 0.11.0+wasi-snapshot-preview1",
- "windows-sys 0.52.0",
]
[[package]]
-name = "mp4ameta"
-version = "0.11.0"
+name = "js-sys"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb23d62e8eb5299a3f79657c70ea9269eac8f6239a76952689bcd06a74057e81"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
- "lazy_static",
- "mp4ameta_proc",
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
]
[[package]]
-name = "mp4ameta_proc"
-version = "0.6.0"
+name = "jsonwebtoken"
+version = "10.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07dcca13d1740c0a665f77104803360da0bdb3323ecce2e93fa2c959a6d52806"
-
-[[package]]
-name = "music-server"
-version = "0.1.0"
+checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
- "actix-cors",
- "actix-embed",
- "actix-files",
- "actix-multipart",
- "actix-web",
- "actix-web-httpauth",
- "actix-web-rust-embed-responder",
- "actix-ws",
- "argon2",
- "audiotags",
- "chrono",
- "diesel",
- "diesel_migrations",
- "dirs",
- "dotenvy",
- "fancy-regex",
- "futures",
- "image 0.25.2",
- "jsonwebtoken",
- "lazy_static",
- "levenshtein",
- "libsqlite3-sys",
- "lofty",
- "meilisearch-sdk",
- "mime_guess",
- "openssl",
- "rand 0.8.5",
- "ravif",
- "rayon",
- "regex",
- "reqwest",
- "rust-embed 8.5.0",
- "scraper",
- "self_update",
+ "aws-lc-rs",
+ "base64",
+ "getrandom 0.2.17",
+ "js-sys",
"serde",
"serde_json",
- "tokio",
- "tokio-util",
- "tracing",
- "tracing-subscriber",
- "uuid",
- "walkdir",
- "webp",
+ "signature",
+ "zeroize",
]
[[package]]
-name = "nasm-rs"
-version = "0.2.5"
+name = "jwalk"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe4d98d0065f4b1daf164b3eafb11974c94662e5e2396cf03f32d0bb5c17da51"
+checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56"
dependencies = [
+ "crossbeam",
"rayon",
]
[[package]]
-name = "native-tls"
-version = "0.2.12"
+name = "kqueue"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
+ "kqueue-sys",
"libc",
- "log",
- "openssl",
- "openssl-probe",
- "openssl-sys",
- "schannel",
- "security-framework",
- "security-framework-sys",
- "tempfile",
]
[[package]]
-name = "ndk"
-version = "0.6.0"
+name = "kqueue-sys"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2032c77e030ddee34a6787a64166008da93f6a352b629261d0fee232b8742dd4"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
- "bitflags 1.3.2",
- "jni-sys",
- "ndk-sys",
- "num_enum",
- "thiserror",
+ "bitflags 2.13.0",
+ "libc",
]
[[package]]
-name = "ndk-context"
-version = "0.1.1"
+name = "language-tags"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
[[package]]
-name = "ndk-sys"
-version = "0.3.0"
+name = "lazy_static"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e5a6ae77c8ee183dcbbba6150e2e6b9f3f4196a7666c02a715a95692ec1fa97"
-dependencies = [
- "jni-sys",
-]
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
-name = "new_debug_unreachable"
-version = "1.0.6"
+name = "libc"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
-name = "new_mime_guess"
-version = "4.0.4"
+name = "libfuzzer-sys"
+version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02a2dfb3559d53e90b709376af1c379462f7fb3085a0177deb73e6ea0d99eff4"
+checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
- "mime",
- "unicase",
+ "arbitrary",
+ "cc",
]
[[package]]
-name = "nodrop"
-version = "0.1.14"
+name = "libm"
+version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
-name = "nom"
-version = "7.1.3"
+name = "libredox"
+version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
- "memchr",
- "minimal-lexical",
+ "libc",
]
[[package]]
-name = "noop_proc_macro"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
-
-[[package]]
-name = "nu-ansi-term"
-version = "0.46.0"
+name = "libsqlite3-sys"
+version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
- "overload",
- "winapi",
+ "cc",
+ "pkg-config",
+ "vcpkg",
]
[[package]]
-name = "num-bigint"
-version = "0.4.6"
+name = "libwebp-sys"
+version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
dependencies = [
- "num-integer",
- "num-traits",
+ "cc",
+ "glob",
]
[[package]]
-name = "num-conv"
-version = "0.1.0"
+name = "linux-raw-sys"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
-name = "num-derive"
-version = "0.4.2"
+name = "litemap"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
-name = "num-integer"
-version = "0.1.46"
+name = "local-channel"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
dependencies = [
- "num-traits",
+ "futures-core",
+ "futures-sink",
+ "local-waker",
]
[[package]]
-name = "num-rational"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
-dependencies = [
- "num-bigint",
- "num-integer",
- "num-traits",
-]
+name = "local-waker"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
[[package]]
-name = "num-traits"
-version = "0.2.19"
+name = "lock_api"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
- "autocfg",
+ "scopeguard",
]
[[package]]
-name = "num_enum"
-version = "0.5.11"
+name = "lofty"
+version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9"
+checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca"
dependencies = [
- "num_enum_derive",
+ "byteorder",
+ "data-encoding",
+ "flate2",
+ "lofty_attr",
+ "log",
+ "ogg_pager",
+ "paste",
]
[[package]]
-name = "num_enum_derive"
-version = "0.5.11"
+name = "lofty_attr"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799"
+checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
dependencies = [
- "proc-macro-crate",
"proc-macro2",
"quote",
- "syn 1.0.109",
+ "syn",
]
[[package]]
-name = "number_prefix"
-version = "0.4.0"
+name = "log"
+version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
-name = "objc"
-version = "0.2.7"
+name = "loop9"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
- "malloc_buf",
- "objc_exception",
+ "imgref",
]
[[package]]
-name = "objc_exception"
+name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
-dependencies = [
- "cc",
-]
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
-name = "objc_id"
-version = "0.1.1"
+name = "matchers"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
- "objc",
+ "regex-automata",
]
[[package]]
-name = "object"
-version = "0.36.3"
+name = "maybe-rayon"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9"
+checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
- "memchr",
+ "cfg-if",
+ "rayon",
]
[[package]]
-name = "ogg_pager"
-version = "0.6.1"
+name = "mdns-sd"
+version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87b0bef808533c5890ab77279538212efdbbbd9aa4ef1ccdfcfbf77a42f7e6fa"
+checksum = "183d7ebd9fe3815c1635cd039d1ed95f1d315731cc13e6d504e4ae039d527b8d"
dependencies = [
- "byteorder",
+ "fastrand",
+ "flume",
+ "if-addrs",
+ "log",
+ "mio",
+ "socket-pktinfo",
+ "socket2 0.6.4",
]
[[package]]
-name = "once_cell"
-version = "1.19.0"
+name = "memchr"
+version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
-name = "openssl"
-version = "0.10.66"
+name = "migrations_internals"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1"
+checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d"
dependencies = [
- "bitflags 2.6.0",
- "cfg-if",
- "foreign-types",
- "libc",
- "once_cell",
- "openssl-macros",
- "openssl-sys",
+ "serde",
+ "toml 0.9.12+spec-1.1.0",
]
[[package]]
-name = "openssl-macros"
-version = "0.1.1"
+name = "migrations_macros"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703"
dependencies = [
+ "migrations_internals",
"proc-macro2",
"quote",
- "syn 2.0.74",
]
[[package]]
-name = "openssl-probe"
-version = "0.1.5"
+name = "mime"
+version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
-name = "openssl-src"
-version = "300.3.1+3.3.1"
+name = "mime_guess"
+version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
- "cc",
+ "mime",
+ "unicase",
]
[[package]]
-name = "openssl-sys"
-version = "0.9.103"
+name = "miniz_oxide"
+version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
- "cc",
- "libc",
- "openssl-src",
- "pkg-config",
- "vcpkg",
+ "adler2",
+ "simd-adler32",
]
[[package]]
-name = "option-ext"
-version = "0.2.0"
+name = "mio"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
[[package]]
-name = "overload"
-version = "0.1.1"
+name = "moxcms"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
[[package]]
-name = "pango"
-version = "0.15.10"
+name = "nasm-rs"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f"
+checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
dependencies = [
- "bitflags 1.3.2",
- "glib",
- "libc",
- "once_cell",
- "pango-sys",
+ "jobserver",
+ "log",
]
[[package]]
-name = "pango-sys"
-version = "0.15.10"
+name = "new_debug_unreachable"
+version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa"
-dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps 6.2.2",
-]
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
-name = "parking_lot"
-version = "0.12.3"
+name = "no_std_io2"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
- "lock_api",
- "parking_lot_core",
+ "memchr",
]
[[package]]
-name = "parking_lot_core"
-version = "0.9.10"
+name = "nom"
+version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-targets 0.52.6",
+ "memchr",
]
[[package]]
-name = "parse-size"
-version = "1.0.0"
+name = "noop_proc_macro"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae"
+checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
-name = "password-hash"
-version = "0.5.0"
+name = "notify"
+version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
- "base64ct",
- "rand_core 0.6.4",
- "subtle",
+ "bitflags 2.13.0",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
]
[[package]]
-name = "paste"
-version = "1.0.15"
+name = "notify-types"
+version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.13.0",
+]
[[package]]
-name = "pem"
-version = "3.0.4"
+name = "nu-ansi-term"
+version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "base64 0.22.1",
- "serde",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "percent-encoding"
-version = "2.3.1"
+name = "num-bigint"
+version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
[[package]]
-name = "phf"
-version = "0.8.0"
+name = "num-conv"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
-dependencies = [
- "phf_macros 0.8.0",
- "phf_shared 0.8.0",
- "proc-macro-hack",
-]
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
-name = "phf"
-version = "0.10.1"
+name = "num-derive"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
- "phf_shared 0.10.0",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "phf"
-version = "0.11.2"
+name = "num-integer"
+version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
- "phf_macros 0.11.2",
- "phf_shared 0.11.2",
+ "num-traits",
]
[[package]]
-name = "phf_codegen"
-version = "0.8.0"
+name = "num-rational"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
- "phf_generator 0.8.0",
- "phf_shared 0.8.0",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
]
[[package]]
-name = "phf_codegen"
-version = "0.10.0"
+name = "num-traits"
+version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
- "phf_generator 0.10.0",
- "phf_shared 0.10.0",
+ "autocfg",
]
[[package]]
-name = "phf_codegen"
-version = "0.11.2"
+name = "ogg_pager"
+version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
+checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
dependencies = [
- "phf_generator 0.11.2",
- "phf_shared 0.11.2",
+ "byteorder",
]
[[package]]
-name = "phf_generator"
-version = "0.8.0"
+name = "once_cell"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526"
-dependencies = [
- "phf_shared 0.8.0",
- "rand 0.7.3",
-]
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
-name = "phf_generator"
-version = "0.10.0"
+name = "openssl-probe"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
-dependencies = [
- "phf_shared 0.10.0",
- "rand 0.8.5",
-]
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
-name = "phf_generator"
-version = "0.11.2"
+name = "parking_lot"
+version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
- "phf_shared 0.11.2",
- "rand 0.8.5",
+ "lock_api",
+ "parking_lot_core",
]
[[package]]
-name = "phf_macros"
-version = "0.8.0"
+name = "parking_lot_core"
+version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
- "phf_generator 0.8.0",
- "phf_shared 0.8.0",
- "proc-macro-hack",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
]
[[package]]
-name = "phf_macros"
-version = "0.11.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b"
+name = "parson-core"
+version = "1.0.0"
dependencies = [
- "phf_generator 0.11.2",
- "phf_shared 0.11.2",
- "proc-macro2",
- "quote",
- "syn 2.0.74",
+ "base64",
+ "diesel",
+ "libsqlite3-sys",
+ "serde",
+ "sha2",
]
[[package]]
-name = "phf_shared"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7"
+name = "parson-music"
+version = "1.0.0"
dependencies = [
- "siphasher",
+ "actix-cors",
+ "actix-multipart",
+ "actix-web",
+ "actix-web-httpauth",
+ "actix-ws",
+ "argon2",
+ "base64",
+ "bytes",
+ "chrono",
+ "diesel",
+ "diesel_migrations",
+ "dirs",
+ "dotenvy",
+ "futures",
+ "globset",
+ "id3",
+ "image",
+ "jsonwebtoken",
+ "jwalk",
+ "libc",
+ "libsqlite3-sys",
+ "lofty",
+ "mdns-sd",
+ "mime_guess",
+ "notify",
+ "parson-core",
+ "percent-encoding",
+ "rand 0.10.2",
+ "ravif",
+ "rayon",
+ "regex",
+ "reqwest",
+ "rust-embed",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tokio",
+ "tokio-uring",
+ "tokio-util",
+ "tracing",
+ "tracing-subscriber",
+ "unicode-normalization",
+ "uuid",
+ "webp",
+ "windows-sys 0.61.2",
+ "zstd",
]
[[package]]
-name = "phf_shared"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
+name = "parson-music-windows-bridge"
+version = "1.0.0"
dependencies = [
- "siphasher",
+ "parson-music",
+ "serde_json",
+ "tokio",
]
[[package]]
-name = "phf_shared"
-version = "0.11.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
+name = "parson-music-windows-server"
+version = "1.0.0"
dependencies = [
- "siphasher",
+ "actix-web",
+ "dirs",
+ "embed-resource",
+ "parson-music",
+ "reqwest",
+ "semver",
+ "serde",
+ "sha2",
+ "tempfile",
+ "tokio",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+ "windows-sys 0.61.2",
+ "winreg",
]
[[package]]
-name = "pin-project"
-version = "1.1.5"
+name = "password-hash"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
- "pin-project-internal",
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
]
[[package]]
-name = "pin-project-internal"
-version = "1.1.5"
+name = "paste"
+version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
-name = "pin-project-lite"
-version = "0.2.14"
+name = "pastey"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
-name = "pin-utils"
-version = "0.1.0"
+name = "percent-encoding"
+version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
-name = "pkcs8"
-version = "0.10.2"
+name = "pin-project-lite"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
-dependencies = [
- "der",
- "spki",
-]
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
-
-[[package]]
-name = "plist"
-version = "1.7.0"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
-dependencies = [
- "base64 0.22.1",
- "indexmap 2.3.0",
- "quick-xml 0.32.0",
- "serde",
- "time",
-]
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
-version = "0.17.13"
+version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
- "bitflags 1.3.2",
+ "bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
- "miniz_oxide 0.7.4",
+ "miniz_oxide",
]
[[package]]
-name = "portable-atomic"
-version = "1.7.0"
+name = "potential_utf"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
[[package]]
name = "powerfmt"
@@ -3689,95 +2510,46 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
-version = "0.2.20"
+version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
-[[package]]
-name = "precomputed-hash"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
-
-[[package]]
-name = "proc-macro-crate"
-version = "1.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
-dependencies = [
- "once_cell",
- "toml_edit 0.19.15",
-]
-
-[[package]]
-name = "proc-macro-error"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
-dependencies = [
- "proc-macro-error-attr",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-error-attr"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
-dependencies = [
- "proc-macro2",
- "quote",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-hack"
-version = "0.5.20+deprecated"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
-
[[package]]
name = "proc-macro2"
-version = "1.0.86"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "profiling"
-version = "1.0.15"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d84d1d7a6ac92673717f9f6d1518374ef257669c24ebc5ac25d5033828be58"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
-version = "1.0.15"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd"
+checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
dependencies = [
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
-name = "qoi"
-version = "0.4.1"
+name = "pxfm"
+version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
-dependencies = [
- "bytemuck",
-]
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
@@ -3785,81 +2557,84 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
-[[package]]
-name = "quick-xml"
-version = "0.23.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "quick-xml"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "quinn"
-version = "0.11.3"
+version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b22d8e7369034b9a7132bc2008cac12f2013c8132b45e0554e6e20e2617f2156"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
+ "cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2",
+ "socket2 0.6.4",
"thiserror",
"tokio",
"tracing",
+ "web-time",
]
[[package]]
name = "quinn-proto"
-version = "0.11.6"
+version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba92fb39ec7ad06ca2582c0ca834dfeadcaf06ddfc8e635c80aa7e1c05315fdd"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
+ "aws-lc-rs",
"bytes",
- "rand 0.8.5",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
"ring",
"rustc-hash",
"rustls",
+ "rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
+ "web-time",
]
[[package]]
name = "quinn-udp"
-version = "0.5.4"
+version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
+ "cfg_aliases",
"libc",
"once_cell",
- "socket2",
+ "socket2 0.6.4",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "quote"
-version = "1.0.36"
+version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "r2d2"
version = "0.8.10"
@@ -3873,94 +2648,79 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.7.3"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
- "getrandom 0.1.16",
- "libc",
- "rand_chacha 0.2.2",
- "rand_core 0.5.1",
- "rand_hc",
- "rand_pcg",
+ "rand_chacha",
+ "rand_core 0.9.5",
]
[[package]]
name = "rand"
-version = "0.8.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
-dependencies = [
- "libc",
- "rand_chacha 0.3.1",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.2.2"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
- "ppv-lite86",
- "rand_core 0.5.1",
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
-version = "0.3.1"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
- "rand_core 0.6.4",
+ "rand_core 0.9.5",
]
[[package]]
name = "rand_core"
-version = "0.5.1"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom 0.1.16",
+ "getrandom 0.2.17",
]
[[package]]
name = "rand_core"
-version = "0.6.4"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.3.4",
]
[[package]]
-name = "rand_hc"
-version = "0.2.0"
+name = "rand_core"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
-dependencies = [
- "rand_core 0.5.1",
-]
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
-version = "0.2.1"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
- "rand_core 0.5.1",
+ "rand_core 0.10.1",
]
[[package]]
name = "rav1e"
-version = "0.7.1"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
+checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
+ "aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
+ "av-scenechange",
"av1-grain",
"bitstream-io",
"built",
@@ -3977,13 +2737,11 @@ dependencies = [
"noop_proc_macro",
"num-derive",
"num-traits",
- "once_cell",
"paste",
"profiling",
- "rand 0.8.5",
- "rand_chacha 0.3.1",
+ "rand 0.9.5",
+ "rand_chacha",
"simd_helpers",
- "system-deps 6.2.2",
"thiserror",
"v_frame",
"wasm-bindgen",
@@ -3991,9 +2749,9 @@ dependencies = [
[[package]]
name = "ravif"
-version = "0.11.10"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8f0bfd976333248de2078d350bfdf182ff96e168a24d23d2436cef320dd4bdd"
+checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
@@ -4004,17 +2762,11 @@ dependencies = [
"rgb",
]
-[[package]]
-name = "raw-window-handle"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
-
[[package]]
name = "rayon"
-version = "1.10.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -4022,317 +2774,210 @@ dependencies = [
[[package]]
name = "rayon-core"
-version = "1.12.1"
+version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
-[[package]]
-name = "readme-rustdocifier"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08ad765b21a08b1a8e5cdce052719188a23772bcbefb3c439f0baaf62c56ceac"
-
[[package]]
name = "redox_syscall"
-version = "0.5.3"
+version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.13.0",
]
[[package]]
name = "redox_users"
-version = "0.4.5"
+version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
-version = "1.10.6"
+version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
+checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
dependencies = [
"aho-corasick",
"memchr",
- "regex-automata 0.4.7",
- "regex-syntax 0.8.4",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
-dependencies = [
- "regex-syntax 0.6.29",
+ "regex-automata",
+ "regex-syntax",
]
[[package]]
name = "regex-automata"
-version = "0.4.7"
+version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
dependencies = [
"aho-corasick",
"memchr",
- "regex-syntax 0.8.4",
+ "regex-syntax",
]
[[package]]
name = "regex-lite"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
-
-[[package]]
-name = "regex-syntax"
-version = "0.6.29"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
-version = "0.8.4"
+version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
-version = "0.12.5"
+version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
- "base64 0.22.1",
+ "base64",
"bytes",
- "encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
- "h2 0.4.5",
- "http 1.1.0",
+ "http 1.4.2",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
- "hyper-tls",
"hyper-util",
- "ipnet",
"js-sys",
"log",
- "mime",
- "native-tls",
- "once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
- "rustls-pemfile",
"rustls-pki-types",
+ "rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
- "system-configuration",
"tokio",
- "tokio-native-tls",
"tokio-rustls",
- "tokio-util",
+ "tower",
+ "tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
"web-sys",
- "webpki-roots",
- "winreg",
]
[[package]]
name = "rgb"
-version = "0.8.48"
+version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f86ae463694029097b846d8f99fd5536740602ae00022c0c50c5600720b2f71"
-dependencies = [
- "bytemuck",
-]
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "ring"
-version = "0.17.8"
+version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom 0.2.15",
+ "getrandom 0.2.17",
"libc",
- "spin",
- "untrusted",
+ "untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]]
-name = "rust-embed"
-version = "6.8.1"
+name = "rsqlite-vfs"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661"
+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
- "rust-embed-impl 6.8.1",
- "rust-embed-utils 7.8.1",
- "walkdir",
+ "hashbrown 0.16.1",
+ "thiserror",
]
[[package]]
name = "rust-embed"
-version = "8.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
-dependencies = [
- "rust-embed-impl 8.5.0",
- "rust-embed-utils 8.5.0",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-for-web"
-version = "11.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acadd634962e6a536f9c0058aaf712b65ee0f21394614b342e8de3feff79c4cc"
-dependencies = [
- "rust-embed-for-web-impl",
- "rust-embed-for-web-utils",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-for-web-impl"
-version = "11.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4a25a96341f5b99dfb29ba21dfe72d8d02109b40be52aac50022c91917bc178"
-dependencies = [
- "brotli",
- "flate2",
- "globset",
- "proc-macro2",
- "quote",
- "rust-embed-for-web-utils",
- "shellexpand",
- "syn 2.0.74",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-for-web-utils"
-version = "11.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cc18e05c3925164af76d54900ac3eb6e0557acbd52504f4878e231161d442dd"
-dependencies = [
- "base85rs",
- "chrono",
- "globset",
- "new_mime_guess",
- "sha2",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-impl"
-version = "6.8.1"
+version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac"
+checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
- "proc-macro2",
- "quote",
- "rust-embed-utils 7.8.1",
- "syn 2.0.74",
+ "rust-embed-impl",
+ "rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
-version = "8.5.0"
+version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
+checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
+ "mime_guess",
"proc-macro2",
"quote",
- "rust-embed-utils 8.5.0",
- "syn 2.0.74",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-utils"
-version = "7.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74"
-dependencies = [
- "sha2",
+ "rust-embed-utils",
+ "syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
-version = "8.5.0"
+version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
+checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"sha2",
"walkdir",
]
-[[package]]
-name = "rustc-demangle"
-version = "0.1.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
-
[[package]]
name = "rustc-hash"
-version = "2.0.0"
+version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
-version = "0.4.0"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
-version = "0.38.34"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "rustls"
-version = "0.23.12"
+version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044"
+checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
+ "aws-lc-rs",
"once_cell",
- "ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -4340,43 +2985,77 @@ dependencies = [
]
[[package]]
-name = "rustls-pemfile"
-version = "2.1.3"
+name = "rustls-native-certs"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
- "base64 0.22.1",
+ "openssl-probe",
"rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "rustls-pki-types"
-version = "1.8.0"
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.102.6"
+version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
- "untrusted",
+ "untrusted 0.9.0",
]
[[package]]
name = "rustversion"
-version = "1.0.17"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
-version = "1.0.18"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
@@ -4389,11 +3068,11 @@ dependencies = [
[[package]]
name = "schannel"
-version = "0.1.23"
+version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4405,41 +3084,19 @@ dependencies = [
"parking_lot",
]
-[[package]]
-name = "scoped-tls"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
-
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-[[package]]
-name = "scraper"
-version = "0.20.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0"
-dependencies = [
- "ahash",
- "cssparser 0.31.2",
- "ego-tree",
- "getopts",
- "html5ever 0.27.0",
- "once_cell",
- "selectors 0.25.0",
- "tendril",
-]
-
[[package]]
name = "security-framework"
-version = "2.11.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.13.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -4448,125 +3105,61 @@ dependencies = [
[[package]]
name = "security-framework-sys"
-version = "2.11.1"
+version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
-name = "selectors"
-version = "0.22.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
-dependencies = [
- "bitflags 1.3.2",
- "cssparser 0.27.2",
- "derive_more",
- "fxhash",
- "log",
- "matches",
- "phf 0.8.0",
- "phf_codegen 0.8.0",
- "precomputed-hash",
- "servo_arc 0.1.1",
- "smallvec",
- "thin-slice",
-]
-
-[[package]]
-name = "selectors"
-version = "0.25.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
-dependencies = [
- "bitflags 2.6.0",
- "cssparser 0.31.2",
- "derive_more",
- "fxhash",
- "log",
- "new_debug_unreachable",
- "phf 0.10.1",
- "phf_codegen 0.10.0",
- "precomputed-hash",
- "servo_arc 0.3.0",
- "smallvec",
-]
-
-[[package]]
-name = "self-replace"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7828a58998685d8bf5a3c5e7a3379a5867289c20828c3ee436280b44b598515"
-dependencies = [
- "fastrand 1.9.0",
- "tempfile",
- "windows-sys 0.48.0",
-]
-
-[[package]]
-name = "self_update"
-version = "0.41.0"
+name = "semver"
+version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469a3970061380c19852269f393e74c0fe607a4e23d85267382cf25486aa8de5"
-dependencies = [
- "hyper",
- "indicatif",
- "log",
- "quick-xml 0.23.1",
- "regex",
- "reqwest",
- "self-replace",
- "semver",
- "serde_json",
- "tempfile",
- "urlencoding",
- "zip",
- "zipsign-api",
-]
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
-name = "semver"
-version = "1.0.23"
+name = "serde"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
- "serde",
+ "serde_core",
+ "serde_derive",
]
[[package]]
-name = "serde"
-version = "1.0.207"
+name = "serde_core"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.207"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "serde_json"
-version = "1.0.124"
+version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
- "indexmap 2.3.0",
- "itoa 1.0.11",
+ "itoa",
"memchr",
- "ryu",
"serde",
+ "serde_core",
+ "zmij",
]
[[package]]
@@ -4578,24 +3171,13 @@ dependencies = [
"serde",
]
-[[package]]
-name = "serde_repr"
-version = "0.1.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
[[package]]
name = "serde_spanned"
-version = "0.6.7"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
- "serde",
+ "serde_core",
]
[[package]]
@@ -4605,102 +3187,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
- "itoa 1.0.11",
+ "itoa",
"ryu",
"serde",
]
-[[package]]
-name = "serde_with"
-version = "3.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857"
-dependencies = [
- "base64 0.22.1",
- "chrono",
- "hex",
- "indexmap 1.9.3",
- "indexmap 2.3.0",
- "serde",
- "serde_derive",
- "serde_json",
- "serde_with_macros",
- "time",
-]
-
-[[package]]
-name = "serde_with_macros"
-version = "3.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350"
-dependencies = [
- "darling",
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "serialize-to-javascript"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
-dependencies = [
- "serde",
- "serde_json",
- "serialize-to-javascript-impl",
-]
-
-[[package]]
-name = "serialize-to-javascript-impl"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
-]
-
-[[package]]
-name = "servo_arc"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432"
-dependencies = [
- "nodrop",
- "stable_deref_trait",
-]
-
-[[package]]
-name = "servo_arc"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44"
-dependencies = [
- "stable_deref_trait",
-]
-
[[package]]
name = "sha1"
-version = "0.10.6"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
]
[[package]]
name = "sha2"
-version = "0.10.8"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
]
[[package]]
@@ -4713,20 +3224,18 @@ dependencies = [
]
[[package]]
-name = "shellexpand"
-version = "3.1.0"
+name = "shlex"
+version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
-dependencies = [
- "dirs",
-]
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
-version = "1.4.2"
+version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
+ "errno",
"libc",
]
@@ -4736,634 +3245,256 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
- "digest",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
-
-[[package]]
-name = "simd_helpers"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
-dependencies = [
- "quote",
-]
-
-[[package]]
-name = "simple_asn1"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085"
-dependencies = [
- "num-bigint",
- "num-traits",
- "thiserror",
- "time",
-]
-
-[[package]]
-name = "siphasher"
-version = "0.3.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
-
-[[package]]
-name = "slab"
-version = "0.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "smallvec"
-version = "1.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
-
-[[package]]
-name = "socket2"
-version = "0.5.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "soup2"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0"
-dependencies = [
- "bitflags 1.3.2",
- "gio",
- "glib",
- "libc",
- "once_cell",
- "soup2-sys",
-]
-
-[[package]]
-name = "soup2-sys"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf"
-dependencies = [
- "bitflags 1.3.2",
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps 5.0.0",
-]
-
-[[package]]
-name = "spin"
-version = "0.9.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
-dependencies = [
- "lock_api",
-]
-
-[[package]]
-name = "spki"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
-dependencies = [
- "base64ct",
- "der",
-]
-
-[[package]]
-name = "stable_deref_trait"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
-
-[[package]]
-name = "state"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b"
-dependencies = [
- "loom",
-]
-
-[[package]]
-name = "string_cache"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b"
-dependencies = [
- "new_debug_unreachable",
- "once_cell",
- "parking_lot",
- "phf_shared 0.10.0",
- "precomputed-hash",
- "serde",
-]
-
-[[package]]
-name = "string_cache_codegen"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988"
-dependencies = [
- "phf_generator 0.10.0",
- "phf_shared 0.10.0",
- "proc-macro2",
- "quote",
-]
-
-[[package]]
-name = "strsim"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-
-[[package]]
-name = "structmeta"
-version = "0.3.0"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329"
-dependencies = [
- "proc-macro2",
- "quote",
- "structmeta-derive",
- "syn 2.0.74",
-]
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
-name = "structmeta-derive"
-version = "0.3.0"
+name = "simd_cesu8"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
+ "rustc_version",
+ "simdutf8",
]
[[package]]
-name = "subtle"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-
-[[package]]
-name = "syn"
-version = "1.0.109"
+name = "simd_helpers"
+version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
- "proc-macro2",
"quote",
- "unicode-ident",
]
[[package]]
-name = "syn"
-version = "2.0.74"
+name = "simdutf8"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
-name = "sync_wrapper"
-version = "1.0.1"
+name = "slab"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
-name = "system-configuration"
-version = "0.5.1"
+name = "smallvec"
+version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
-dependencies = [
- "bitflags 1.3.2",
- "core-foundation",
- "system-configuration-sys",
-]
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
-name = "system-configuration-sys"
-version = "0.5.0"
+name = "socket-pktinfo"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
+checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
dependencies = [
- "core-foundation-sys",
"libc",
+ "socket2 0.6.4",
+ "windows-sys 0.60.2",
]
[[package]]
-name = "system-deps"
-version = "5.0.0"
+name = "socket2"
+version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e"
+checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d"
dependencies = [
- "cfg-expr 0.9.1",
- "heck 0.3.3",
- "pkg-config",
- "toml 0.5.11",
- "version-compare 0.0.11",
+ "libc",
+ "winapi",
]
[[package]]
-name = "system-deps"
-version = "6.2.2"
+name = "socket2"
+version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
- "cfg-expr 0.15.8",
- "heck 0.5.0",
- "pkg-config",
- "toml 0.8.19",
- "version-compare 0.2.0",
+ "libc",
+ "windows-sys 0.52.0",
]
[[package]]
-name = "tao"
-version = "0.16.10"
+name = "socket2"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48d298c441a1da46e28e8ad8ec205aab7fd8cd71b9d10e05454224eef422e1ae"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
- "bitflags 1.3.2",
- "cairo-rs",
- "cc",
- "cocoa",
- "core-foundation",
- "core-graphics",
- "crossbeam-channel",
- "dispatch",
- "gdk",
- "gdk-pixbuf",
- "gdk-sys",
- "gdkwayland-sys",
- "gdkx11-sys",
- "gio",
- "glib",
- "glib-sys",
- "gtk",
- "image 0.24.9",
- "instant",
- "jni",
- "lazy_static",
"libc",
- "log",
- "ndk",
- "ndk-context",
- "ndk-sys",
- "objc",
- "once_cell",
- "parking_lot",
- "png",
- "raw-window-handle",
- "scopeguard",
- "serde",
- "tao-macros",
- "unicode-segmentation",
- "uuid",
- "windows 0.39.0",
- "windows-implement",
- "x11-dl",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "tao-macros"
-version = "0.1.3"
+name = "spin"
+version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
+ "lock_api",
]
[[package]]
-name = "tar"
-version = "0.4.41"
+name = "sqlite-wasm-rs"
+version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb797dad5fb5b76fcf519e702f4a589483b5ef06567f160c392832c1f5e44909"
+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
- "filetime",
- "libc",
- "xattr",
+ "cc",
+ "js-sys",
+ "rsqlite-vfs",
+ "wasm-bindgen",
]
[[package]]
-name = "target-lexicon"
-version = "0.12.16"
+name = "stable_deref_trait"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
-name = "tauri"
-version = "1.7.2"
+name = "strsim"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e33e3ba00a3b05eb6c57ef135781717d33728b48acf914bb05629e74d897d29"
-dependencies = [
- "anyhow",
- "cocoa",
- "dirs-next",
- "dunce",
- "embed_plist",
- "encoding_rs",
- "flate2",
- "futures-util",
- "getrandom 0.2.15",
- "glib",
- "glob",
- "gtk",
- "heck 0.5.0",
- "http 0.2.12",
- "ignore",
- "objc",
- "once_cell",
- "percent-encoding",
- "rand 0.8.5",
- "raw-window-handle",
- "semver",
- "serde",
- "serde_json",
- "serde_repr",
- "serialize-to-javascript",
- "state",
- "tar",
- "tauri-macros",
- "tauri-runtime",
- "tauri-runtime-wry",
- "tauri-utils",
- "tempfile",
- "thiserror",
- "tokio",
- "url",
- "uuid",
- "webkit2gtk",
- "webview2-com",
- "windows 0.39.0",
-]
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
-name = "tauri-build"
-version = "1.5.4"
+name = "subtle"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5fb5a90a64241ddb7217d3210d844149070a911e87e8a107a707a1d4973f164"
-dependencies = [
- "anyhow",
- "cargo_toml",
- "dirs-next",
- "heck 0.5.0",
- "json-patch",
- "semver",
- "serde",
- "serde_json",
- "tauri-utils",
- "tauri-winres",
- "walkdir",
-]
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
-name = "tauri-codegen"
-version = "1.4.5"
+name = "symlink"
+version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93a9e3f5cebf779a63bf24903e714ec91196c307d8249a0008b882424328bcda"
-dependencies = [
- "base64 0.21.7",
- "brotli",
- "ico",
- "json-patch",
- "plist",
- "png",
- "proc-macro2",
- "quote",
- "semver",
- "serde",
- "serde_json",
- "sha2",
- "tauri-utils",
- "thiserror",
- "time",
- "uuid",
- "walkdir",
-]
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
-name = "tauri-macros"
-version = "1.4.6"
+name = "syn"
+version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1d0e989f54fe06c5ef0875c5e19cf96453d099a0a774d5192ab47e80471cdab"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
- "heck 0.5.0",
"proc-macro2",
"quote",
- "syn 1.0.109",
- "tauri-codegen",
- "tauri-utils",
-]
-
-[[package]]
-name = "tauri-runtime"
-version = "0.14.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f33fda7d213e239077fad52e96c6b734cecedb30c2382118b64f94cb5103ff3a"
-dependencies = [
- "gtk",
- "http 0.2.12",
- "http-range",
- "rand 0.8.5",
- "raw-window-handle",
- "serde",
- "serde_json",
- "tauri-utils",
- "thiserror",
- "url",
- "uuid",
- "webview2-com",
- "windows 0.39.0",
+ "unicode-ident",
]
[[package]]
-name = "tauri-runtime-wry"
-version = "0.14.10"
+name = "sync_wrapper"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18c447dcd9b0f09c7dc4b752cc33e72788805bfd761fbda5692d30c48289efec"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
- "cocoa",
- "gtk",
- "percent-encoding",
- "rand 0.8.5",
- "raw-window-handle",
- "tauri-runtime",
- "tauri-utils",
- "uuid",
- "webkit2gtk",
- "webview2-com",
- "windows 0.39.0",
- "wry",
+ "futures-core",
]
[[package]]
-name = "tauri-utils"
-version = "1.6.1"
+name = "synstructure"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83a0c939e88d82903a0a7dfb28388b12a3c03504d6bd6086550edaa3b6d8beaa"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
- "brotli",
- "ctor",
- "dunce",
- "glob",
- "heck 0.5.0",
- "html5ever 0.26.0",
- "infer",
- "json-patch",
- "kuchikiki",
- "log",
- "memchr",
- "phf 0.11.2",
"proc-macro2",
"quote",
- "semver",
- "serde",
- "serde_json",
- "serde_with",
- "thiserror",
- "url",
- "walkdir",
- "windows-version",
-]
-
-[[package]]
-name = "tauri-winres"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb"
-dependencies = [
- "embed-resource",
- "toml 0.7.8",
+ "syn",
]
[[package]]
name = "tempfile"
-version = "3.12.0"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
- "cfg-if",
- "fastrand 2.1.0",
+ "fastrand",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "tendril"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
-dependencies = [
- "futf",
- "mac",
- "utf-8",
+ "windows-sys 0.61.2",
]
-[[package]]
-name = "thin-slice"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
-
[[package]]
name = "thiserror"
-version = "1.0.63"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "1.0.63"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "thread_local"
-version = "1.1.8"
+version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
- "once_cell",
-]
-
-[[package]]
-name = "tiff"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
-dependencies = [
- "flate2",
- "jpeg-decoder",
- "weezl",
]
[[package]]
name = "time"
-version = "0.3.36"
+version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
- "itoa 1.0.11",
"num-conv",
"powerfmt",
- "serde",
+ "serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
-version = "0.1.2"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
-version = "0.2.18"
+version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
]
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
[[package]]
name = "tinyvec"
-version = "1.8.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
@@ -5376,155 +3507,169 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.40.0"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
- "backtrace",
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2",
+ "socket2 0.6.4",
"tokio-macros",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
-version = "2.4.0"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
-name = "tokio-native-tls"
-version = "0.3.1"
+name = "tokio-rustls"
+version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "native-tls",
+ "rustls",
"tokio",
]
[[package]]
-name = "tokio-rustls"
-version = "0.26.0"
+name = "tokio-uring"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
+checksum = "748482e3e13584a34664a710168ad5068e8cb1d968aa4ffa887e83ca6dd27967"
dependencies = [
- "rustls",
- "rustls-pki-types",
+ "futures-util",
+ "io-uring",
+ "libc",
+ "slab",
+ "socket2 0.4.10",
"tokio",
]
[[package]]
name = "tokio-util"
-version = "0.7.11"
+version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
+ "futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
-version = "0.5.11"
+version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
- "serde",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "winnow 0.7.15",
]
[[package]]
name = "toml"
-version = "0.7.8"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
+checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
- "serde",
+ "indexmap",
+ "serde_core",
"serde_spanned",
- "toml_datetime",
- "toml_edit 0.19.15",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.4",
]
[[package]]
-name = "toml"
-version = "0.8.19"
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
- "serde",
- "serde_spanned",
- "toml_datetime",
- "toml_edit 0.22.20",
+ "serde_core",
]
[[package]]
name = "toml_datetime"
-version = "0.6.8"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
- "serde",
+ "serde_core",
]
[[package]]
-name = "toml_edit"
-version = "0.19.15"
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "indexmap 2.3.0",
- "serde",
- "serde_spanned",
- "toml_datetime",
- "winnow 0.5.40",
+ "winnow 1.0.4",
]
[[package]]
-name = "toml_edit"
-version = "0.22.20"
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d"
-dependencies = [
- "indexmap 2.3.0",
- "serde",
- "serde_spanned",
- "toml_datetime",
- "winnow 0.6.18",
-]
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
-version = "0.4.13"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
- "pin-project",
"pin-project-lite",
+ "sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.0",
+ "bytes",
+ "futures-util",
+ "http 1.4.2",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
[[package]]
name = "tower-layer"
-version = "0.3.2"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
@@ -5534,9 +3679,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
-version = "0.1.40"
+version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -5544,22 +3689,35 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-appender"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
+dependencies = [
+ "crossbeam-channel",
+ "symlink",
+ "thiserror",
+ "time",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tracing-attributes"
-version = "0.1.27"
+version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
name = "tracing-core"
-version = "0.1.32"
+version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -5578,14 +3736,14 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
-version = "0.3.18"
+version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
- "regex",
+ "regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
@@ -5602,51 +3760,48 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
-version = "1.17.0"
+version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
-dependencies = [
- "version_check",
-]
-
-[[package]]
-name = "unicode-bidi"
-version = "0.3.15"
+version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
-version = "1.0.12"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
-version = "0.1.23"
+version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
-version = "1.11.0"
+version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
-name = "unicode-width"
-version = "0.1.13"
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
@@ -5656,9 +3811,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
-version = "2.5.2"
+version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
@@ -5667,63 +3822,40 @@ dependencies = [
]
[[package]]
-name = "urlencoding"
-version = "2.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
-
-[[package]]
-name = "utf-8"
-version = "0.7.6"
+name = "utf8_iter"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
-dependencies = [
- "getrandom 0.2.15",
- "rand 0.8.5",
- "serde",
- "uuid-macro-internal",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "uuid-macro-internal"
-version = "1.10.0"
+version = "1.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee1cd046f83ea2c4e920d6ee9f7c3537ef928d75dce5d84a87c2c5d6b3999a3a"
+checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.74",
+ "getrandom 0.4.3",
+ "js-sys",
+ "rand 0.10.2",
+ "serde_core",
+ "wasm-bindgen",
]
[[package]]
name = "v_frame"
-version = "0.3.8"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b"
+checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
-[[package]]
-name = "v_htmlescape"
-version = "0.15.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
-
[[package]]
name = "valuable"
-version = "0.1.0"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
@@ -5731,18 +3863,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
-[[package]]
-name = "version-compare"
-version = "0.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b"
-
-[[package]]
-name = "version-compare"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
-
[[package]]
name = "version_check"
version = "0.9.5"
@@ -5761,9 +3881,9 @@ dependencies = [
[[package]]
name = "vswhom-sys"
-version = "0.1.2"
+version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
@@ -5790,59 +3910,47 @@ dependencies = [
[[package]]
name = "wasi"
-version = "0.9.0+wasi-snapshot-preview1"
+version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
-name = "wasi"
-version = "0.11.0+wasi-snapshot-preview1"
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
[[package]]
name = "wasm-bindgen"
-version = "0.2.93"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
+ "rustversion",
"wasm-bindgen-macro",
-]
-
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.93"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
-dependencies = [
- "bumpalo",
- "log",
- "once_cell",
- "proc-macro2",
- "quote",
- "syn 2.0.74",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.43"
+version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
- "cfg-if",
"js-sys",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.93"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5850,156 +3958,65 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.93"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
+ "bumpalo",
"proc-macro2",
"quote",
- "syn 2.0.74",
- "wasm-bindgen-backend",
+ "syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.93"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
-
-[[package]]
-name = "wasm-streams"
-version = "0.4.0"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
- "futures-util",
- "js-sys",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
+ "unicode-ident",
]
[[package]]
name = "web-sys"
-version = "0.3.70"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
-name = "webkit2gtk"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370"
-dependencies = [
- "bitflags 1.3.2",
- "cairo-rs",
- "gdk",
- "gdk-sys",
- "gio",
- "gio-sys",
- "glib",
- "glib-sys",
- "gobject-sys",
- "gtk",
- "gtk-sys",
- "javascriptcore-rs",
- "libc",
- "once_cell",
- "soup2",
- "webkit2gtk-sys",
-]
-
-[[package]]
-name = "webkit2gtk-sys"
-version = "0.18.0"
+name = "web-time"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
- "atk-sys",
- "bitflags 1.3.2",
- "cairo-sys-rs",
- "gdk-pixbuf-sys",
- "gdk-sys",
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "gtk-sys",
- "javascriptcore-rs-sys",
- "libc",
- "pango-sys",
- "pkg-config",
- "soup2-sys",
- "system-deps 6.2.2",
+ "js-sys",
+ "wasm-bindgen",
]
[[package]]
name = "webp"
-version = "0.3.0"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f53152f51fb5af0c08484c33d16cca96175881d1f3dec068c23b31a158c2d99"
+checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
dependencies = [
- "image 0.25.2",
+ "image",
"libwebp-sys",
]
[[package]]
-name = "webpki-roots"
-version = "0.26.3"
+name = "webpki-root-certs"
+version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd"
+checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
dependencies = [
"rustls-pki-types",
]
-[[package]]
-name = "webview2-com"
-version = "0.19.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178"
-dependencies = [
- "webview2-com-macros",
- "webview2-com-sys",
- "windows 0.39.0",
- "windows-implement",
-]
-
-[[package]]
-name = "webview2-com-macros"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eaebe196c01691db62e9e4ca52c5ef1e4fd837dcae27dae3ada599b5a8fd05ac"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "webview2-com-sys"
-version = "0.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aac48ef20ddf657755fdcda8dfed2a7b4fc7e4581acce6fe9b88c3d64f29dee7"
-dependencies = [
- "regex",
- "serde",
- "serde_json",
- "thiserror",
- "windows 0.39.0",
- "windows-bindgen",
- "windows-metadata",
-]
-
-[[package]]
-name = "weezl"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
-
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6018,11 +4035,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
-version = "0.1.9"
+version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6032,70 +4049,62 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
-name = "windows"
-version = "0.39.0"
+name = "windows-core"
+version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
- "windows_aarch64_msvc 0.39.0",
- "windows_i686_gnu 0.39.0",
- "windows_i686_msvc 0.39.0",
- "windows_x86_64_gnu 0.39.0",
- "windows_x86_64_msvc 0.39.0",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
]
[[package]]
-name = "windows"
-version = "0.48.0"
+name = "windows-implement"
+version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
- "windows-targets 0.48.5",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "windows-bindgen"
-version = "0.39.0"
+name = "windows-interface"
+version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68003dbd0e38abc0fb85b939240f4bce37c43a5981d3df37ccbaaa981b47cb41"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
- "windows-metadata",
- "windows-tokens",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "windows-core"
-version = "0.52.0"
+name = "windows-link"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
-dependencies = [
- "windows-targets 0.52.6",
-]
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
-name = "windows-implement"
-version = "0.39.0"
+name = "windows-result"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba01f98f509cb5dc05f4e5fc95e535f78260f15fea8fe1a8abdd08f774f1cee7"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
- "syn 1.0.109",
- "windows-tokens",
+ "windows-link",
]
[[package]]
-name = "windows-metadata"
-version = "0.39.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278"
-
-[[package]]
-name = "windows-sys"
-version = "0.48.0"
+name = "windows-strings"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
- "windows-targets 0.48.5",
+ "windows-link",
]
[[package]]
@@ -6117,18 +4126,21 @@ dependencies = [
]
[[package]]
-name = "windows-targets"
-version = "0.48.5"
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
- "windows_aarch64_gnullvm 0.48.5",
- "windows_aarch64_msvc 0.48.5",
- "windows_i686_gnu 0.48.5",
- "windows_i686_msvc 0.48.5",
- "windows_x86_64_gnu 0.48.5",
- "windows_x86_64_gnullvm 0.48.5",
- "windows_x86_64_msvc 0.48.5",
+ "windows-link",
]
[[package]]
@@ -6140,7 +4152,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm",
+ "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
@@ -6148,26 +4160,22 @@ dependencies = [
]
[[package]]
-name = "windows-tokens"
-version = "0.39.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597"
-
-[[package]]
-name = "windows-version"
-version = "0.1.1"
+name = "windows-targets"
+version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6998aa457c9ba8ff2fb9f13e9d2a930dabcea28f1d0ab94d687d8b3654844515"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
]
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
-
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -6175,16 +4183,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
-name = "windows_aarch64_msvc"
-version = "0.39.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.48.5"
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
@@ -6193,22 +4195,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
-name = "windows_i686_gnu"
-version = "0.39.0"
+name = "windows_aarch64_msvc"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
-version = "0.48.5"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
-version = "0.52.6"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
@@ -6217,256 +4219,245 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
-name = "windows_i686_msvc"
-version = "0.39.0"
+name = "windows_i686_gnullvm"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
-version = "0.48.5"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
-version = "0.52.6"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
-version = "0.39.0"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
-version = "0.48.5"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
-name = "windows_x86_64_gnu"
+name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
-version = "0.48.5"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
-name = "windows_x86_64_gnullvm"
+name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
-version = "0.39.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
-name = "windows_x86_64_msvc"
-version = "0.48.5"
+name = "winnow"
+version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
+name = "winnow"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
-name = "winnow"
-version = "0.5.40"
+name = "winreg"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
- "memchr",
+ "cfg-if",
+ "windows-sys 0.59.0",
]
[[package]]
-name = "winnow"
-version = "0.6.18"
+name = "wit-bindgen"
+version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f"
-dependencies = [
- "memchr",
-]
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
-name = "winreg"
-version = "0.52.0"
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "y4m"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
- "cfg-if",
- "windows-sys 0.48.0",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
]
[[package]]
-name = "wry"
-version = "0.24.11"
+name = "yoke-derive"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c55c80b12287eb1ff7c365fc2f7a5037cb6181bd44c9fce81c8d1cf7605ffad6"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
- "base64 0.13.1",
- "block",
- "cocoa",
- "core-graphics",
- "crossbeam-channel",
- "dunce",
- "gdk",
- "gio",
- "glib",
- "gtk",
- "html5ever 0.26.0",
- "http 0.2.12",
- "kuchikiki",
- "libc",
- "log",
- "objc",
- "objc_id",
- "once_cell",
- "serde",
- "serde_json",
- "sha2",
- "soup2",
- "tao",
- "thiserror",
- "url",
- "webkit2gtk",
- "webkit2gtk-sys",
- "webview2-com",
- "windows 0.39.0",
- "windows-implement",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
]
[[package]]
-name = "x11"
-version = "2.21.0"
+name = "zerocopy"
+version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
- "libc",
- "pkg-config",
+ "zerocopy-derive",
]
[[package]]
-name = "x11-dl"
-version = "2.21.0"
+name = "zerocopy-derive"
+version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
- "libc",
- "once_cell",
- "pkg-config",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
-name = "xattr"
-version = "1.3.1"
+name = "zerofrom"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
- "libc",
- "linux-raw-sys",
- "rustix",
+ "zerofrom-derive",
]
[[package]]
-name = "yaup"
-version = "0.3.1"
+name = "zerofrom-derive"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
- "form_urlencoded",
- "serde",
- "thiserror",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
]
[[package]]
-name = "zerocopy"
-version = "0.7.35"
+name = "zeroize"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
- "byteorder",
- "zerocopy-derive",
+ "zeroize_derive",
]
[[package]]
-name = "zerocopy-derive"
-version = "0.7.35"
+name = "zeroize_derive"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.74",
+ "syn",
]
[[package]]
-name = "zeroize"
-version = "1.8.1"
+name = "zerotrie"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
[[package]]
-name = "zip"
-version = "2.2.0"
+name = "zerovec"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc5e4288ea4057ae23afc69a4472434a87a2495cafce6632fd1c4ec9f5cf3494"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
- "arbitrary",
- "crc32fast",
- "crossbeam-utils",
- "displaydoc",
- "indexmap 2.3.0",
- "memchr",
- "thiserror",
- "time",
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
]
[[package]]
-name = "zipsign-api"
-version = "0.1.2"
+name = "zerovec-derive"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6413a546ada9dbcd0b9a3e0b0880581279e35047bce9797e523b3408e1df607c"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
- "ed25519-dalek",
- "thiserror",
+ "proc-macro2",
+ "quote",
+ "syn",
]
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
[[package]]
name = "zstd"
-version = "0.13.2"
+version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
-version = "7.2.1"
+version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
-version = "2.0.13+zstd.1.5.6"
+version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
@@ -6474,24 +4465,15 @@ dependencies = [
[[package]]
name = "zune-core"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
-
-[[package]]
-name = "zune-inflate"
-version = "0.2.54"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
-dependencies = [
- "simd-adler32",
-]
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
-version = "0.4.13"
+version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5c8cfbcd..adc084b2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,15 +1,14 @@
[workspace]
-members = [
- "apps/web/src-tauri",
- "crates/backend",
- # "crates/release"
-]
-
+members = ["crates/backend", "crates/parson-core", "crates/windows-bridge", "crates/windows-server"]
resolver = "2"
-# [profile.release]
-# opt-level = "z"
-# lto = true
-# codegen-units = 1
-# panic = 'abort'
-# strip = true
+[profile.dev]
+opt-level = 1
+debug = true
+incremental = true
+
+[profile.release]
+lto = "fat"
+codegen-units = 1
+panic = "abort"
+strip = "symbols"
diff --git a/Cross.toml b/Cross.toml
deleted file mode 100644
index 8bfe811c..00000000
--- a/Cross.toml
+++ /dev/null
@@ -1,28 +0,0 @@
-[target.x86_64-unknown-linux-musl]
-pre-build = [
- "apt-get update",
- "apt-get install -y nasm"
-]
-
-[target.aarch64-unknown-linux-musl]
-pre-build = [
- "apt-get update",
- "apt-get install -y nasm"
-]
-
-[target.armv7-unknown-linux-musleabihf]
-pre-build = [
- "apt-get update",
- "apt-get install -y nasm"
-]
-
-[target.arm-unknown-linux-musleabihf]
-pre-build = [
- "apt-get update",
- "apt-get install -y nasm"
-]
-
-[target.x86_64-pc-windows-gnu]
-pre-build = [
- "choco install nasm"
-]
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 8c58b57c..1734593d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,89 +1,50 @@
-# Stage 1: Build the React frontend
-FROM oven/bun:alpine AS frontend-builder
-RUN apk update
-RUN apk add --no-cache yarn
-WORKDIR /app
-COPY . .
-
-RUN bun install --ignore-scripts
-RUN bun install turbo --global
-RUN turbo prune music --docker
+FROM oven/bun:1.3.14-alpine AS bun-runtime
-# Stage 2: Install dependencies and build the project
-FROM oven/bun:alpine AS installer
+FROM node:24-alpine AS web-deps
WORKDIR /app
-COPY --from=frontend-builder /app/out/json/ .
-COPY --from=frontend-builder /app/out/yarn.lock ./yarn.lock
-
-RUN bun install --ignore-scripts
-RUN bun install yarn --global
-RUN apk add --no-cache nodejs-current
-COPY --from=frontend-builder /app/out/full/ .
-COPY ./package.json ./package.json
-COPY ./yarn.lock ./yarn.lock
-
-WORKDIR /app/apps/web
-
-RUN yarn build
-
-# Stage 3: Build the Rust backend
-FROM rust:slim-buster AS backend-builder
-WORKDIR /usr/src
+ENV NEXT_TELEMETRY_DISABLED=1
+COPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun
-RUN apt-get update && apt-get install -y --no-install-recommends \
- sqlite3 libsqlite3-dev wget make build-essential pkg-config libssl-dev \
- && rm -rf /var/lib/apt/lists/*
+COPY package.json bun.lock turbo.json ./
+COPY apps/web ./apps/web
+COPY packages ./packages
+RUN bun install --frozen-lockfile
-RUN wget https://www.nasm.us/pub/nasm/releasebuilds/2.16/nasm-2.16.tar.gz \
- && tar xzf nasm-2.16.tar.gz \
- && cd nasm-2.16 \
- && ./configure \
- && make \
- && make install \
- && cd .. \
- && rm -rf nasm-2.16 nasm-2.16.tar.gz
-
-RUN cargo install diesel_cli --no-default-features --features sqlite
+FROM web-deps AS web-builder
+RUN bun --filter parson-music-web build
-COPY ./crates/backend /usr/src/crates/backend
-COPY ./diesel.toml /usr/src/diesel.toml
-COPY --from=installer /app/apps/web/out /usr/src/apps/web/out
-
-WORKDIR /usr/src/crates/backend
-
-ENV DATABASE_URL=sqlite:///usr/src/crates/backend/music.db
+FROM rust:1.97-slim-trixie AS backend-builder
+WORKDIR /app
-RUN diesel migration run --config-file /usr/src/diesel.toml
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends nasm \
+ && rm -rf /var/lib/apt/lists/*
-RUN cargo build --release
+COPY Cargo.toml Cargo.lock ./
+COPY crates/backend ./crates/backend
+COPY crates/parson-core ./crates/parson-core
+COPY crates/windows-bridge ./crates/windows-bridge
+COPY crates/windows-server ./crates/windows-server
+COPY --from=web-builder /app/apps/web/out ./apps/web/out
+RUN cargo build --release -p parson-music
-# Stage 4: Create the final image
-FROM debian:bullseye-slim AS runner
+FROM debian:trixie-slim AS runner
WORKDIR /app
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libssl1.1 \
- sqlite3 \
- libsqlite3-dev \
- wget \
- ca-certificates \
- && rm -rf /var/lib/apt/lists/*
-
-RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
-
-# Change ownership of the /app directory to the nextjs user
-RUN chown -R nextjs:nodejs /app
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
-USER nextjs
-COPY --from=backend-builder --chown=nextjs:nodejs /usr/src/crates/backend/target/release/music-server /usr/local/bin/music-server
-COPY --from=backend-builder --chown=nextjs:nodejs /usr/src/crates/backend/music.db /usr/src/crates/backend/music.db
-COPY --from=installer --chown=nextjs:nodejs /app/apps/web/out ./apps/web/out
+RUN useradd --system --uid 10001 parson \
+ && install -d -o parson -g parson /Parson
-# Set SSL_CERT_FILE environment variable
-ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
+COPY --from=backend-builder /app/target/release/parson-music-server /usr/local/bin/parson-music-server
+ENV RUNNING_IN_DOCKER=true
+ENV PARSON_PORT=1993
EXPOSE 1993
-EXPOSE 7700
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD curl --fail --silent http://127.0.0.1:1993/health || exit 1
+USER parson
-CMD ["/usr/local/bin/music-server", "--port", "1993"]
\ No newline at end of file
+ENTRYPOINT ["/usr/local/bin/parson-music-server"]
diff --git a/LICENSE b/LICENSE
index 343499cc..b8dd7a9f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
- ParsonLabs Music Copyright (C) 2024 WillKirkmanM
+ Parson Copyright (C) 2024-2026 WillKirkmanM
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
diff --git a/README.md b/README.md
index 44be56cc..cf7919a7 100644
--- a/README.md
+++ b/README.md
@@ -1,145 +1,6 @@
-
-
-
-ParsonLabs Music
+# Parson
-
-
-
+Parson is a self-hosted, local-first music server and player for the web,
+desktop, and mobile.
-
-
-ParsonLabs Music is the Self Hosted Audio streaming alternative to YouTube Music, Spotify & Apple Music, providing Unrestricted Access to your library in Uncompressed, Lossless Quality
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## Get Started
-
-### Docker
-
-#### Docker Run
-```sh
-docker run -d \
- --name parsonlabs-music \
- -p 1993:1993 \
- -p 7700:7700 \
- -v "/path/to/config:/ParsonLabsMusic" \
- -v "/path/to/music:/music" \
- -e JWT_SECRET=your_auth_secret_here \
- --restart unless-stopped \
- ghcr.io/willkirkmanm/music
-```
-
-#### Docker Compose
-```yaml
-services:
- music-server:
- image: ghcr.io/willkirkmanm/music
- container_name: parsonlabs-music
- ports:
- - "1993:1993"
- - "7700:7700"
- volumes:
- - "/path/to/config:/ParsonLabsMusic"
- - "/path/to/music:/music"
- environment:
- - JWT_SECRET=your_auth_secret_here
- restart: unless-stopped
-```
-
-### Download Precompiled Binaries
-
-You can download the precompiled binaries from the [releases page](https://github.com/WillKirkmanM/music/releases).
-
-- [Windows (x86_64)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-x86_64-pc-windows-gnu.zip)
-- [Linux (x86_64)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-x86_64-unknown-linux-gnu.tar.gz)
-- [Linux (x86_64 MUSL)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-x86_64-unknown-linux-musl.tar.gz)
-- [Linux (ARMv7 gnueabihf)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-armv7-unknown-linux-gnueabihf.tar.gz)
-- [Linux (ARMv7 MUSL)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-armv7-unknown-linux-musleabihf.tar.gz)
-- [Linux (ARM gnueabihf)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-arm-unknown-linux-gnueabihf.tar.gz)
-- [Linux (ARM MUSL)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-arm-unknown-linux-musleabihf.tar.gz)
-- [Linux (ARM64)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-aarch64-unknown-linux-gnu.tar.gz)
-- [Linux (ARM64 MUSL)](https://github.com/WillKirkmanM/music/releases/download/v1.2.0-alpha/music-server-aarch64-unknown-linux-musl.tar.gz)
-
-### Compile From Source
-
-### One Liner
-```
-git clone https://github.com/WillKirkmanM/music && cd music && OS=$(uname -s) && if [ "$OS" = "Linux" ]; then if [ -f /etc/debian_version ]; then sudo apt-get update && sudo apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev wget make build-essential pkg-config libssl-dev unzip && wget https://www.nasm.us/pub/nasm/releasebuilds/2.16/nasm-2.16.tar.gz && tar xzf nasm-2.16.tar.gz && cd nasm-2.16 && ./configure && make && sudo make install && cd .. && rm -rf nasm-2.16 nasm-2.16.tar.gz && sudo apt-get install -y libssl1.1 && rm -rf /var/lib/apt/lists/* && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn; elif [ -f /etc/arch-release ]; then sudo pacman -Syu --noconfirm sqlite wget make base-devel pkgconf openssl nasm unzip && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn; elif [ -f /etc/fedora-release ]; then sudo dnf install -y sqlite sqlite-devel wget make gcc gcc-c++ kernel-devel pkgconf-pkg-config openssl-devel nasm unzip && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn; elif [ -f /etc/gentoo-release ]; then sudo emerge --sync && sudo emerge --ask sqlite wget make gcc pkgconfig openssl nasm unzip && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn; else echo "Unsupported Linux distribution" && exit 1; fi; else echo "Unsupported OS" && exit 1; fi && bun run build --filter music && cargo build --package music-server --release && cargo run --package music-server --release
-```
-
-### Clone the Repository
-```
-git clone https://github.com/WillKirkmanM/music
-```
-
-### Install Dependencies
-#### Ubuntu
-```
-sudo apt-get update && apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev wget make build-essential pkg-config libssl-dev unzip && wget https://www.nasm.us/pub/nasm/releasebuilds/2.16/nasm-2.16.tar.gz && tar xzf nasm-2.16.tar.gz && cd nasm-2.16 && ./configure && make && make install && cd .. && rm -rf nasm-2.16 nasm-2.16.tar.gz && apt-get install -y libssl1.1 && rm -rf /var/lib/apt/lists/* && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn
-```
-#### Arch Linux
-```
-sudo pacman -Syu --noconfirm sqlite wget make base-devel pkgconf openssl nasm unzip && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn
-```
-#### Fedora
-```
-sudo dnf install -y sqlite sqlite-devel wget make gcc gcc-c++ kernel-devel pkgconf-pkg-config openssl-devel nasm unzip && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn
-```
-
-#### Gentoo
-```
-sudo emerge --sync && sudo emerge --ask sqlite wget make gcc pkgconfig openssl nasm unzip && curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH" && bun install --global yarn
-```
-
-### Build the Website
-```
-bun run build --filter music
-```
-### Build the Server
-```
-cargo build --package music-server --release
-```
-
-> [!TIP]
-> The authentication is handled by [JWT Tokens](https://jwt.io/)), they require a special `JWT_SECRET` in order to safely protect your accounts against third parties.
-> It is reccommended to `cp .env.example .env` and edit the `JWT_SECRET=` value to something secret, alternatively, you can use openssl to generate a random secret: `openssl rand -base64 48`.
-
-### Run ParsonLabs Music
-```
-cargo run --package music-server --release -p 1993
-```
-
-> [!NOTE]
-> **Done! 🥳**.
-> Head to [http://localhost:1993/](http://localhost:1993/) and setup your music library.
-
-## Documentation
-Any additional troubleshooting information can be found in the Documentation https://docs.parsonlabs.com.
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..45b7c015
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,18 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are provided for the latest 1.0.x release.
+
+## Deployment boundary
+
+Plain HTTP and automatic discovery are intended for trusted local networks.
+Use an HTTPS reverse proxy and set `PARSON_PUBLIC_URL` for internet access. A
+remote first-account registration requires the setup code printed by the server;
+localhost setup does not. Keep the `/Parson` data directory and server logs
+private because they contain authentication material and the temporary setup
+code.
+
+## Reporting a vulnerability
+
+Please report vulnerabilities privately through GitHub Security Advisories for this repository. Do not open a public issue with exploit details. Include affected versions, reproduction steps, impact, and any proposed mitigation. You should receive an acknowledgement within seven days.
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
new file mode 100644
index 00000000..d912fa54
--- /dev/null
+++ b/apps/desktop/README.md
@@ -0,0 +1,27 @@
+# Parson desktop client
+
+Electron client for Linux and Windows.
+
+## Development
+
+```powershell
+bun --filter parson-music-desktop desktop:dev
+```
+
+## Packaging
+
+```powershell
+bun --filter parson-music-desktop desktop:build
+```
+
+Linux package check:
+
+```bash
+./apps/desktop/scripts/verify-linux-package.sh
+```
+
+Windows release:
+
+```powershell
+.\apps\desktop\scripts\package-windows.ps1 -Version 1.0.0
+```
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
new file mode 100644
index 00000000..8cf68c90
--- /dev/null
+++ b/apps/desktop/electron/main.cjs
@@ -0,0 +1,219 @@
+const {
+ app,
+ BrowserWindow,
+ dialog,
+ ipcMain,
+ Menu,
+ shell,
+} = require("electron");
+const { spawn } = require("node:child_process");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const PORT = 1993;
+const ORIGIN = `http://127.0.0.1:${PORT}`;
+const STARTUP_POLL_MS = 10;
+let backend = null;
+let mainWindow = null;
+let quitting = false;
+
+const primary = app.requestSingleInstanceLock();
+if (!primary) {
+ app.quit();
+} else {
+ app.on("second-instance", () => showMainWindow());
+}
+
+function showMainWindow() {
+ if (!mainWindow || mainWindow.isDestroyed()) return;
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.show();
+ mainWindow.focus();
+}
+
+function backendExecutable() {
+ const executable = `parson-music-server${process.platform === "win32" ? ".exe" : ""}`;
+ if (app.isPackaged) return path.join(process.resourcesPath, executable);
+ return path.join(__dirname, "bin", executable);
+}
+
+function rotateBackendLog(logPath) {
+ try {
+ if (fs.statSync(logPath).size <= 2 * 1024 * 1024) return;
+ fs.renameSync(logPath, `${logPath}.previous`);
+ } catch (error) {
+ if (error?.code !== "ENOENT")
+ console.error("Could not rotate backend log", error);
+ }
+}
+
+async function isParsonReady() {
+ try {
+ const response = await fetch(`${ORIGIN}/.well-known/parson`, {
+ signal: AbortSignal.timeout(750),
+ cache: "no-store",
+ });
+ if (!response.ok) return false;
+ const manifest = await response.json();
+ return (
+ manifest.protocol === "parson" && manifest.product === "parson-music"
+ );
+ } catch {
+ return false;
+ }
+}
+
+async function startBackend() {
+ if (await isParsonReady()) return;
+ const executable = backendExecutable();
+ if (!fs.existsSync(executable)) {
+ throw new Error(`The packaged Parson backend is missing: ${executable}`);
+ }
+ const logsDirectory = app.getPath("logs");
+ fs.mkdirSync(logsDirectory, { recursive: true });
+ const logPath = path.join(logsDirectory, "backend.log");
+ rotateBackendLog(logPath);
+ const log = fs.openSync(logPath, "a");
+ backend = spawn(executable, [], {
+ cwd: path.dirname(executable),
+ env: {
+ ...process.env,
+ PARSON_BIND_ADDRESS: "0.0.0.0",
+ PARSON_PORT: String(PORT),
+ PARSON_PUBLIC_URL: ORIGIN,
+ },
+ stdio: ["ignore", log, log],
+ });
+ fs.closeSync(log);
+ backend.once("exit", (code, signal) => {
+ backend = null;
+ if (!quitting && !mainWindow?.isDestroyed()) {
+ void mainWindow.loadFile(path.join(__dirname, "startup-error.html"), {
+ query: { detail: `Backend stopped (${code ?? signal ?? "unknown"}).` },
+ });
+ }
+ });
+ const deadline = Date.now() + 30_000;
+ while (Date.now() < deadline) {
+ if (await isParsonReady()) return;
+ if (!backend) throw new Error("The Parson backend stopped during startup.");
+ await new Promise((resolve) => setTimeout(resolve, STARTUP_POLL_MS));
+ }
+ throw new Error("The Parson backend did not become ready within 30 seconds.");
+}
+
+function installDesktopBridge() {
+ ipcMain.handle("parson:window-control", (event, action) => {
+ const window = BrowserWindow.fromWebContents(event.sender);
+ if (!window || window.isDestroyed()) return false;
+ switch (action) {
+ case "minimize":
+ window.minimize();
+ return true;
+ case "toggle-maximize":
+ if (window.isMaximized()) window.unmaximize();
+ else window.maximize();
+ return window.isMaximized();
+ case "is-maximized":
+ return window.isMaximized();
+ case "close":
+ window.close();
+ return true;
+ default:
+ throw new Error(`Unknown window control: ${action}`);
+ }
+ });
+
+ ipcMain.handle("parson:invoke", async (event, command, args = {}) => {
+ if (new URL(event.senderFrame.url).origin !== ORIGIN) {
+ throw new Error(
+ "Desktop commands are only available to the local Parson app.",
+ );
+ }
+ switch (command) {
+ case "platform":
+ return process.platform === "win32" ? "windows" : "linux";
+ case "select_music_folder": {
+ const selection = await dialog.showOpenDialog(mainWindow, {
+ properties: ["openDirectory", "createDirectory"],
+ title: "Choose your music folder",
+ });
+ return selection.canceled ? null : (selection.filePaths[0] ?? null);
+ }
+ case "show_track_in_file_manager": {
+ const target = typeof args.path === "string" ? args.path : "";
+ if (!target || !fs.existsSync(target))
+ throw new Error("A valid track path is required.");
+ shell.showItemInFolder(target);
+ return true;
+ }
+ default:
+ throw new Error(`Unknown desktop command: ${command}`);
+ }
+ });
+}
+
+async function createWindow() {
+ mainWindow = new BrowserWindow({
+ width: 1280,
+ height: 820,
+ minWidth: 900,
+ minHeight: 600,
+ show: true,
+ backgroundColor: "#000000",
+ autoHideMenuBar: true,
+ frame: false,
+ title: "Parson",
+ icon: app.isPackaged
+ ? path.join(process.resourcesPath, "parson.png")
+ : path.join(__dirname, "../../web/public/icons/icon-512.png"),
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ preload: path.join(__dirname, "preload.cjs"),
+ },
+ });
+ const publishMaximizedState = () => {
+ if (!mainWindow?.isDestroyed()) {
+ mainWindow.webContents.send("parson:maximized", mainWindow.isMaximized());
+ }
+ };
+ mainWindow.on("maximize", publishMaximizedState);
+ mainWindow.on("unmaximize", publishMaximizedState);
+ mainWindow.maximize();
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ if (url.startsWith("http://") || url.startsWith("https://"))
+ void shell.openExternal(url);
+ return { action: "deny" };
+ });
+ mainWindow.webContents.on("will-navigate", (event, url) => {
+ if (url.startsWith(ORIGIN) || url.startsWith("file:")) return;
+ event.preventDefault();
+ });
+ await mainWindow.loadFile(path.join(__dirname, "startup.html"));
+ try {
+ await startBackend();
+ await mainWindow.loadURL(ORIGIN);
+ } catch (error) {
+ await mainWindow.loadFile(path.join(__dirname, "startup-error.html"), {
+ query: { detail: error instanceof Error ? error.message : String(error) },
+ });
+ }
+}
+
+if (primary) {
+ app.whenReady().then(async () => {
+ app.setName("Parson");
+ Menu.setApplicationMenu(null);
+ app.setAsDefaultProtocolClient("parson");
+ installDesktopBridge();
+ await createWindow();
+ });
+}
+
+app.on("window-all-closed", () => app.quit());
+app.on("before-quit", () => {
+ quitting = true;
+ if (backend && !backend.killed) backend.kill("SIGTERM");
+});
diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs
new file mode 100644
index 00000000..05368107
--- /dev/null
+++ b/apps/desktop/electron/preload.cjs
@@ -0,0 +1,20 @@
+const { contextBridge, ipcRenderer } = require("electron");
+
+contextBridge.exposeInMainWorld("__PARSON_ELECTRON__", {
+ invoke(command, args) {
+ return ipcRenderer.invoke("parson:invoke", command, args);
+ },
+ windowControls: {
+ close: () => ipcRenderer.invoke("parson:window-control", "close"),
+ isMaximized: () =>
+ ipcRenderer.invoke("parson:window-control", "is-maximized"),
+ minimize: () => ipcRenderer.invoke("parson:window-control", "minimize"),
+ toggleMaximize: () =>
+ ipcRenderer.invoke("parson:window-control", "toggle-maximize"),
+ watchMaximized: (callback) => {
+ ipcRenderer.on("parson:maximized", (_event, maximized) =>
+ callback(Boolean(maximized)),
+ );
+ },
+ },
+});
diff --git a/apps/desktop/electron/startup-error.html b/apps/desktop/electron/startup-error.html
new file mode 100644
index 00000000..2cf561ba
--- /dev/null
+++ b/apps/desktop/electron/startup-error.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+ Parson could not start
+
+
+
+
+ − □ ×
+
+
+ Parson could not start
+ The local server did not become available.
+
+
+
+
diff --git a/apps/desktop/electron/startup.html b/apps/desktop/electron/startup.html
new file mode 100644
index 00000000..97630a75
--- /dev/null
+++ b/apps/desktop/electron/startup.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+ Parson
+
+
+
+
+ − □ ×
+
+ P
+
+
+
diff --git a/apps/desktop/flatpak/com.parsonlabs.Parson.yml b/apps/desktop/flatpak/com.parsonlabs.Parson.yml
new file mode 100644
index 00000000..4fad2831
--- /dev/null
+++ b/apps/desktop/flatpak/com.parsonlabs.Parson.yml
@@ -0,0 +1,22 @@
+app-id: com.parsonlabs.Parson
+runtime: org.gnome.Platform
+runtime-version: "49"
+sdk: org.gnome.Sdk
+command: parson-music-linux
+finish-args:
+ - --share=ipc
+ - --share=network
+ - --socket=wayland
+ - --socket=fallback-x11
+ - --socket=pulseaudio
+ - --device=dri
+ - --talk-name=org.freedesktop.Notifications
+ - --talk-name=org.freedesktop.portal.Desktop
+modules:
+ - name: parson
+ buildsystem: simple
+ build-commands:
+ - install -Dm755 Parson.AppImage /app/bin/parson-music-linux
+ sources:
+ - type: file
+ path: Parson.AppImage
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..df580cec
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "parson-music-desktop",
+ "version": "1.0.0",
+ "private": true,
+ "license": "GPL-3.0-only",
+ "description": "Parson desktop client for Linux and Windows",
+ "homepage": "https://github.com/ParsonLabs/Parson",
+ "author": {
+ "name": "ParsonLabs"
+ },
+ "desktopName": "com.parsonlabs.parson.desktop",
+ "main": "electron/main.cjs",
+ "scripts": {
+ "desktop:dev": "node scripts/prepare-sidecar.cjs && electron .",
+ "desktop:build": "node scripts/prepare-sidecar.cjs && node scripts/build.cjs"
+ },
+ "devDependencies": {
+ "electron": "43.1.1",
+ "electron-builder": "26.15.3"
+ },
+ "build": {
+ "appId": "com.parsonlabs.parson",
+ "productName": "Parson",
+ "asar": true,
+ "directories": {
+ "output": "../../target/release/bundle/electron"
+ },
+ "files": [
+ "electron/main.cjs",
+ "electron/preload.cjs",
+ "electron/startup.html",
+ "electron/startup-error.html",
+ "package.json"
+ ],
+ "extraResources": [
+ {
+ "from": "electron/bin",
+ "to": ".",
+ "filter": [
+ "parson-music-server",
+ "parson-music-server.exe"
+ ]
+ },
+ {
+ "from": "../web/public/icons/icon-512.png",
+ "to": "parson.png"
+ }
+ ],
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb"
+ ],
+ "category": "AudioVideo;Audio;Player",
+ "executableName": "parson",
+ "icon": "../web/public/icons/icon-512.png",
+ "maintainer": "ParsonLabs",
+ "syncDesktopName": true,
+ "artifactName": "Parson_${version}_${arch}.${ext}"
+ },
+ "win": {
+ "target": [
+ "nsis"
+ ],
+ "icon": "../../crates/windows-server/parson.ico",
+ "executableName": "Parson",
+ "artifactName": "Parson_${version}_${arch}-setup.${ext}"
+ },
+ "nsis": {
+ "oneClick": false,
+ "allowToChangeInstallationDirectory": true,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true,
+ "shortcutName": "Parson"
+ }
+ }
+}
diff --git a/apps/desktop/scripts/build-linux.sh b/apps/desktop/scripts/build-linux.sh
new file mode 100644
index 00000000..47bd9cf7
--- /dev/null
+++ b/apps/desktop/scripts/build-linux.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+workspace="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+cd "$workspace/apps/desktop"
+bunx electron-builder --linux AppImage
+
+if ldconfig -p 2>/dev/null | grep -q 'libcrypt\.so\.1'; then
+ bunx electron-builder --linux deb
+else
+ echo "Skipping optional .deb: this host does not provide libcrypt.so.1 for electron-builder's fpm." >&2
+fi
diff --git a/apps/desktop/scripts/build.cjs b/apps/desktop/scripts/build.cjs
new file mode 100644
index 00000000..d8792ab2
--- /dev/null
+++ b/apps/desktop/scripts/build.cjs
@@ -0,0 +1,52 @@
+const { spawnSync } = require("node:child_process");
+const { existsSync, readdirSync, rmSync } = require("node:fs");
+const path = require("node:path");
+
+const desktopDirectory = path.resolve(__dirname, "..");
+const outputDirectory = path.resolve(
+ desktopDirectory,
+ "../../target/release/bundle/electron",
+);
+
+function run(command, args) {
+ const result = spawnSync(command, args, {
+ cwd: desktopDirectory,
+ stdio: "inherit",
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ throw new Error(`${command} exited with status ${result.status}`);
+ }
+}
+
+function cleanOutput(predicate) {
+ if (!existsSync(outputDirectory)) return;
+ for (const entry of readdirSync(outputDirectory, { withFileTypes: true })) {
+ if (!predicate(entry.name)) continue;
+ rmSync(path.join(outputDirectory, entry.name), {
+ recursive: true,
+ force: true,
+ });
+ }
+}
+
+if (process.platform === "linux") {
+ cleanOutput(
+ (name) =>
+ name === "linux-unpacked" ||
+ name.endsWith(".AppImage") ||
+ name.endsWith(".deb"),
+ );
+ run("bash", [path.join(__dirname, "build-linux.sh")]);
+} else if (process.platform === "win32") {
+ cleanOutput((name) => name === "win-unpacked" || name.endsWith("-setup.exe"));
+ run(process.execPath, [
+ require.resolve("electron-builder/cli.js"),
+ "--win",
+ "nsis",
+ ]);
+} else {
+ throw new Error(
+ `Desktop packaging is not configured for ${process.platform}.`,
+ );
+}
diff --git a/apps/desktop/scripts/package-windows.ps1 b/apps/desktop/scripts/package-windows.ps1
new file mode 100644
index 00000000..34a4ca53
--- /dev/null
+++ b/apps/desktop/scripts/package-windows.ps1
@@ -0,0 +1,32 @@
+param(
+ [ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')]
+ [string]$Version = "1.0.0",
+ [string]$OutputDirectory = "artifacts\windows"
+)
+
+$ErrorActionPreference = "Stop"
+$workspace = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\..\..")).Path
+$previousLocation = Get-Location
+Set-Location -LiteralPath $workspace
+try {
+ $configuredVersion = (Get-Content -LiteralPath (Join-Path $workspace "package.json") -Raw | ConvertFrom-Json).version
+ if ($Version -ne $configuredVersion) {
+ throw "Requested version $Version does not match package.json version $configuredVersion."
+ }
+
+ & bun install --frozen-lockfile
+ if ($LASTEXITCODE -ne 0) { throw "Installing JavaScript dependencies failed." }
+ & bun --filter parson-music-desktop desktop:build
+ if ($LASTEXITCODE -ne 0) { throw "Building the Windows Electron installer failed." }
+
+ $bundle = Join-Path $workspace "target\release\bundle\electron"
+ $output = Join-Path $workspace $OutputDirectory
+ New-Item -ItemType Directory -Path $output -Force | Out-Null
+ Get-ChildItem -LiteralPath $bundle -File -Filter "*.exe" |
+ Copy-Item -Destination $output -Force
+
+ Write-Output "Created Windows Electron installer(s) in: $output"
+}
+finally {
+ Set-Location -LiteralPath $previousLocation
+}
diff --git a/apps/desktop/scripts/prepare-sidecar.cjs b/apps/desktop/scripts/prepare-sidecar.cjs
new file mode 100644
index 00000000..84745f1b
--- /dev/null
+++ b/apps/desktop/scripts/prepare-sidecar.cjs
@@ -0,0 +1,47 @@
+const {
+ copyFileSync,
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ unlinkSync,
+} = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const workspace = path.resolve(__dirname, "../../..");
+const binDirectory = path.join(workspace, "apps", "desktop", "electron", "bin");
+const windows = process.platform === "win32";
+const executable = `parson-music-server${windows ? ".exe" : ""}`;
+
+function run(command, args, cwd = workspace) {
+ const result = spawnSync(command, args, { cwd, stdio: "inherit" });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ throw new Error(`${command} exited with status ${result.status}`);
+ }
+}
+
+run("bun", ["run", "build"], path.join(workspace, "apps", "web"));
+run("cargo", [
+ "build",
+ "--manifest-path",
+ path.join(workspace, "Cargo.toml"),
+ "-p",
+ "parson-music",
+ "--release",
+]);
+
+mkdirSync(binDirectory, { recursive: true });
+const otherExecutable = path.join(
+ binDirectory,
+ windows ? "parson-music-server" : "parson-music-server.exe",
+);
+if (existsSync(otherExecutable)) unlinkSync(otherExecutable);
+const source = path.join(workspace, "target", "release", executable);
+const destination = path.join(binDirectory, executable);
+copyFileSync(source, destination);
+if (!windows) chmodSync(destination, 0o755);
+
+console.log(
+ `Prepared the shared Electron shell with the ${process.platform} backend.`,
+);
diff --git a/apps/desktop/scripts/verify-linux-package.sh b/apps/desktop/scripts/verify-linux-package.sh
new file mode 100644
index 00000000..6d3bdac7
--- /dev/null
+++ b/apps/desktop/scripts/verify-linux-package.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+bundle_dir="${1:-target/release/bundle/electron}"
+bundle_dir="$(realpath "$bundle_dir")"
+mapfile -t debs < <(find "$bundle_dir" -maxdepth 1 -type f -name '*.deb' -print)
+mapfile -t appimages < <(find "$bundle_dir" -maxdepth 1 -type f -name '*.AppImage' -print)
+
+if [[ "${#debs[@]}" -gt 1 || "${#appimages[@]}" -ne 1 ]]; then
+ echo "Expected one AppImage and at most one optional .deb in $bundle_dir" >&2
+ exit 1
+fi
+
+test -s "${appimages[0]}"
+if [[ "${#debs[@]}" -eq 1 ]]; then
+ test -s "${debs[0]}"
+ if command -v dpkg-deb >/dev/null; then
+ dpkg-deb --info "${debs[0]}" >/dev/null
+ else
+ ar t "${debs[0]}" | grep -qx 'debian-binary'
+ ar t "${debs[0]}" | grep -q '^control\.tar'
+ ar t "${debs[0]}" | grep -q '^data\.tar'
+ fi
+fi
+chmod +x "${appimages[0]}"
+extract_dir="$(mktemp -d -t parson-package-verify-XXXXXX)"
+(cd "$extract_dir" && "${appimages[0]}" --appimage-extract >/dev/null)
+test -x "$extract_dir/squashfs-root/AppRun"
+test -x "$extract_dir/squashfs-root/resources/parson-music-server"
+test -f "$extract_dir/squashfs-root/resources/app.asar"
+rm -rf "$extract_dir"
+
+sha256sum "${appimages[0]}" "${debs[@]}"
diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore
deleted file mode 100644
index 6976b71c..00000000
--- a/apps/docs/.gitignore
+++ /dev/null
@@ -1,27 +0,0 @@
-# deps
-/node_modules
-
-# generated content
-.map.ts
-.contentlayer
-
-# test & build
-/coverage
-/.next/
-/out/
-/build
-*.tsbuildinfo
-
-# misc
-.DS_Store
-*.pem
-/.pnp
-.pnp.js
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# others
-.env*.local
-.vercel
-next-env.d.ts
\ No newline at end of file
diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts
deleted file mode 100644
index 74983f97..00000000
--- a/apps/docs/app/api/search/route.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { getPages } from '@/app/source';
-import { createSearchAPI } from 'fumadocs-core/search/server';
-
-export const { GET } = createSearchAPI('advanced', {
- indexes: getPages().map((page) => ({
- title: page.data.title,
- structuredData: page.data.exports.structuredData,
- id: page.url,
- url: page.url,
- })),
-});
diff --git a/apps/docs/app/docs/[[...slug]]/page.tsx b/apps/docs/app/docs/[[...slug]]/page.tsx
deleted file mode 100644
index 901da0f3..00000000
--- a/apps/docs/app/docs/[[...slug]]/page.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { getPage, getPages } from '@/app/source';
-import type { Metadata } from 'next';
-import { DocsPage, DocsBody } from 'fumadocs-ui/page';
-import { notFound } from 'next/navigation';
-
-export default async function Page({
- params,
-}: {
- params: { slug?: string[] };
-}) {
- const page = getPage(params.slug);
-
- if (page == null) {
- notFound();
- }
-
- const MDX = page.data.exports.default;
-
- return (
-
-
- {page.data.title}
-
-
-
- );
-}
-
-export async function generateStaticParams() {
- return getPages().map((page) => ({
- slug: page.slugs,
- }));
-}
-
-export function generateMetadata({ params }: { params: { slug?: string[] } }) {
- const page = getPage(params.slug);
-
- if (page == null) notFound();
-
- return {
- title: page.data.title,
- description: page.data.description,
- } satisfies Metadata;
-}
diff --git a/apps/docs/app/docs/layout.tsx b/apps/docs/app/docs/layout.tsx
deleted file mode 100644
index cc286a85..00000000
--- a/apps/docs/app/docs/layout.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { pageTree } from '../source';
-import { DocsLayout } from 'fumadocs-ui/layout';
-import { RollButton } from "fumadocs-ui/components/roll-button"
-import type { ReactNode } from 'react';
-import { Metadata } from 'next';
-
-export const metadata: Metadata = {
- title: {
- default: "ParsonLabs Documentation",
- template: "%s | ParsonLabs Documentation"
- }
-}
-
-export default function RootDocsLayout({ children }: { children: ReactNode }) {
- return (
-
-
- {children}
-
- );
-}
diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css
deleted file mode 100644
index b5c61c95..00000000
--- a/apps/docs/app/global.css
+++ /dev/null
@@ -1,3 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx
deleted file mode 100644
index c8007816..00000000
--- a/apps/docs/app/layout.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import "./global.css"
-import { RootProvider } from 'fumadocs-ui/provider';
-import { Inter } from 'next/font/google';
-import type { ReactNode } from 'react';
-
-const inter = Inter({
- subsets: ['latin'],
-});
-
-export default function Layout({ children }: { children: ReactNode }) {
- return (
-
-
- {children}
-
-
- );
-}
diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx
deleted file mode 100644
index 7f74e058..00000000
--- a/apps/docs/app/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function HomePage() {
- redirect("/docs")
-}
diff --git a/apps/docs/app/source.ts b/apps/docs/app/source.ts
deleted file mode 100644
index 5cb887be..00000000
--- a/apps/docs/app/source.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { map } from '@/.map';
-import { createMDXSource } from 'fumadocs-mdx';
-import { loader } from 'fumadocs-core/source';
-
-export const { getPage, getPages, pageTree } = loader({
- baseUrl: '/docs',
- rootDir: 'docs',
- source: createMDXSource(map),
-});
diff --git a/apps/docs/content/docs/contributing/contribution-guide.mdx b/apps/docs/content/docs/contributing/contribution-guide.mdx
deleted file mode 100644
index 7093078d..00000000
--- a/apps/docs/content/docs/contributing/contribution-guide.mdx
+++ /dev/null
@@ -1,16 +0,0 @@
----
-title: Contribution Guide
-description: Guide to Contributing to the ParsonLabs Music repository
----
-
-In order to contribute, it is essential that you follow our core ethos when developing.
-
-## What should I work on?
-We use Github Projects to manage feature requests, the kanban board [can be found here](https://github.com/users/WillKirkmanM/projects/2).
-
- }
-/>
diff --git a/apps/docs/content/docs/contributing/how-to.mdx b/apps/docs/content/docs/contributing/how-to.mdx
deleted file mode 100644
index 4ec51cdb..00000000
--- a/apps/docs/content/docs/contributing/how-to.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: How To
-description: Guide on finding information not covered in the documentation
----
-A Guide on finding information that is not covered in the documentation.
-
-## Who does this apply to?
-When there is a question you have that is not answered in the documentation, it is most likely not the only time the question will be asked and will need to be put in the documentation
-
-## How To
-To ask a question not covered in the Documentation, it is recommended to create a discussion on Github
-
-
- }
- />
-
- }
- />
-
-
-
- It is possible that someone with the same question has asked and recieved an answer! Before you ask a question, make sure it hasn't been answered in both [Discussions](https://github.com/WillKirkmanM/music/discussions) and [Issues](https://github.com/WillKirkmanM/music/issues)
-
\ No newline at end of file
diff --git a/apps/docs/content/docs/contributing/index.mdx b/apps/docs/content/docs/contributing/index.mdx
deleted file mode 100644
index e7a040d1..00000000
--- a/apps/docs/content/docs/contributing/index.mdx
+++ /dev/null
@@ -1,7 +0,0 @@
----
-title: Contributing
-description: Guides to Contributing to the ParsonLabs Music Repository
-index: true
----
-
-Guides to Contributing to the ParsonLabs Music Repository
\ No newline at end of file
diff --git a/apps/docs/content/docs/contributing/meta.json b/apps/docs/content/docs/contributing/meta.json
deleted file mode 100644
index 8b15c408..00000000
--- a/apps/docs/content/docs/contributing/meta.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "pages": [
- "contribution-guide",
- "how-to",
- "reporting-bugs",
- "requesting-features"
- ]
-}
\ No newline at end of file
diff --git a/apps/docs/content/docs/contributing/reporting-bugs.mdx b/apps/docs/content/docs/contributing/reporting-bugs.mdx
deleted file mode 100644
index 64d24220..00000000
--- a/apps/docs/content/docs/contributing/reporting-bugs.mdx
+++ /dev/null
@@ -1,48 +0,0 @@
----
-title: Reporting Bugs
-description: Guide to Reporting Bugs through Github Issues
----
-
-Bugs can be terrible for the user experience and need to be fixed. To do this, we need the help of dilligent users to concisely but effectively detail the aspects of a bug they experience, this page will contain information on how to successfully report a bug to the music developers.
-
-## Requirements for Reporting a Bug
-In order for a "bug" to qualify as a bug, it needs to fufil these properties:
-
-- It harms the user experience
-- It is not intended by the developers (a feature)
-
-### Steps to Effectively create an issue
-
-
- Explain what the issue is
-
-
- Document through images how the issue appears
-
-
- Write the intended behaviour
-
-
- Submit the Issue
-
-
-
-## How to report a bug
-
-
- }
- />
-
- }
- />
-
-
-It may be the case where someone has reported this issue in the past, only for it to be clarified as a feature in music. Check whether this is the case by [heading to the issues page](https://github.com/WillKirkmanM/music/issues). If this is not the case, feel free to [create an issue](https://github.com/WillKirkmanM/music/issues/new).
diff --git a/apps/docs/content/docs/contributing/requesting-features.mdx b/apps/docs/content/docs/contributing/requesting-features.mdx
deleted file mode 100644
index 77260d03..00000000
--- a/apps/docs/content/docs/contributing/requesting-features.mdx
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Requesting Features
-description: Guide to Requesting Features through Github Issues
----
-
-Sometimes there is something you would really like to see in music. No matter the complexity, we will be happy to work on enhancing music into the ultimate streaming platform.
-
-## Requirements for Requesting a Feature
-In order to consider a feature, it needs to fufil these properties:
-
-- It enhances the user experience
-- It does not go against our ethos
-
-### Steps to Effectively create an issue
-
-
- Explain what the feature is in detail
-
-
- Provide images of a pre-existing / pictoral representation of the feature
-
-
- Submit the Discussion
-
-
-
-## How to Request a Feature
-
-
- }
- />
-
- }
- />
-
-
-It may be the case where someone has requested a feature in the past, only for it to be clarified as to why it is or is not in music. Check whether this is the case by [heading to the feature request page](https://github.com/WillKirkmanM/music/discussions/categories/feature-request). If this is not the case, feel free to [create a feature request](https://github.com/WillKirkmanM/music/discussions/new?category=feature-request).
-
diff --git a/apps/docs/content/docs/getting-started/docker.mdx b/apps/docs/content/docs/getting-started/docker.mdx
deleted file mode 100644
index abcc6be8..00000000
--- a/apps/docs/content/docs/getting-started/docker.mdx
+++ /dev/null
@@ -1,147 +0,0 @@
----
-title: Docker
-description: Deploy ParsonLabs Music to a Docker Container
----
-
-We use Docker Compose to orchestrate the deployments of both the frontend and the backend containers.
-
-
-
-
-
-
-
-
-
-
-In the root of the repository, you will see two files, `docker-compose.yml` and `docker-compose-dev.yml`, these will be used to deploy music on both `production` and `development` environments
-
-## For Production
-
-### Editing docker-compose.yml
-To build the app in production, you will need to edit the `docker-compose.yml` file
-
-```yaml title="docker-compose.yml"
-version: '3'
-services:
- frontend:
- build:
- context: .
- dockerfile: ./docker/production/Dockerfile.frontend
- ports:
- - "3000:3000"
- volumes:
- - MEDIA_PATH:/Music
- - MISSING_COVER_ART_PATH:/missing_cover_art
- - CONFIG_PATH:/Config
- environment:
- - BACKEND_PORT=3001
- - NEXTAUTH_SECRET=SALKDNAKFB@!WQ#
- - NEXTAUTH_URL=http://localhost:3000
- - NODE_ENV=production
- - DEPLOYMENT_TYPE=docker
- backend:
- build:
- context: .
- dockerfile: ./docker/production/Dockerfile.backend
- ports:
- - "3001:3001"
- volumes:
- - MEDIA_PATH:/Music
- - MISSING_COVER_ART_PATH:/missing_cover_art
- - CONFIG_PATH:/Config
-```
-
-More specifically, the volumes `MEDIA_PATH`, `MISSING_COVER_ART_PATH` and `CONFIG_PATH` will need to be mapped to the respective paths on the host system. The `NEXTAUTH_URL` will also need to be changed to the intended redirect url after a user is logged in.
-
-### Starting music in docker-compose
-To start music, when the volumes and environment variables have been set, run the command:
-
-```bash
-docker-compose up -d
-```
-
-This will start both the `frontend` and `backend` containers as a `daemon`.
-
-
- Running the containers as a daemon or detached means that the containers will run in the background and means you can safely exit the console.
-
-
-
-## For Development
-
-### Editing docker-compose.yml
-To build the app in production, you will need to edit the `docker-compose-dev.yml` file
-
-```yaml title="docker-compose-dev.yml"
-version: '3.8'
-
-x-defaults:
- &defaults
- init: true
- tty: true
- networks:
- - monorepo_network
-
-services:
- frontend:
- build:
- context: .
- dockerfile: ./docker/development/Dockerfile.frontend
- args:
- - APP=music
- - DIR_NAME=web
- - START_COMMAND=dev
-
- command: yarn workspace music dev
- ports:
- - "3000:3000"
- volumes:
- - ./apps/web:/app/apps/web
- - /app/apps/web/node_modules
- - /app/apps/web/.next
- - /apps/web/node_modules
-
- - MEDIA_PATH:/Music
- - MISSING_COVER_ART_PATH:/missing_cover_art
- - CONFIG_PATH:/Config
- environment:
- - PORT=3000
- - BACKEND_PORT=3001
- - NEXTAUTH_SECRET=SALKDNAKFB@!WQ#
- - NEXTAUTH_URL=http://localhost:3000
- - NODE_ENV=development
- - DEPLOYMENT_TYPE=docker
- backend:
- build:
- context: .
- dockerfile: ./docker/development/Dockerfile.backend
- ports:
- - "3001:3001"
- volumes:
- - ./apps/web/backend:/app/src
-
- - MEDIA_PATH:/Music
- - MISSING_COVER_ART_PATH:/missing_cover_art
- - CONFIG_PATH:/Config
- environment:
- - NODE_ENV=development
-
-networks:
- monorepo_network:
-```
-
-
-More specifically, the volumes `MEDIA_PATH`, `MISSING_COVER_ART_PATH` and `CONFIG_PATH` will need to be mapped to the respective paths on the host system. The `NEXTAUTH_URL` will also need to be changed to the intended redirect url after a user is logged in.
-
-### Starting music in docker-compose
-To start music, when the volumes and environment variables have been set, run the command:
-
-```bash
-docker-compose -f docker-compose-dev.yml up
-```
-
-
- The `-f` flag specifies the dockerfile that Docker Compose will use, by default it will use `docker-compose.yml` and it needs to be before the running command (such as `up` or `down`). If the above does not work, add `COMPOSE_FILE` as an environment variable and the name of the docker compose file for the same function.
-
-
diff --git a/apps/docs/content/docs/getting-started/index.mdx b/apps/docs/content/docs/getting-started/index.mdx
deleted file mode 100644
index 5324cb7b..00000000
--- a/apps/docs/content/docs/getting-started/index.mdx
+++ /dev/null
@@ -1,7 +0,0 @@
----
-title: Getting Started
-description: Guides on Getting Started
-index: true
----
-
-Welcome to the Getting Started page, currently we have support for TrueNas Scale, Docker and Local installations.
\ No newline at end of file
diff --git a/apps/docs/content/docs/getting-started/locally.mdx b/apps/docs/content/docs/getting-started/locally.mdx
deleted file mode 100644
index fd0b0eb0..00000000
--- a/apps/docs/content/docs/getting-started/locally.mdx
+++ /dev/null
@@ -1,58 +0,0 @@
----
-title: Locally
-description: Deploy it Locally
----
-
-
-To build music, it is recommended to add the `music.json` configuration file to the web folder during build to generate the static pages:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ### Install dependencies
- ```bash
- bun install
- ```
-
- This will install all dependencies for all the apps and packages which includes `web`, `docs` and `landing`
-
-
-
-
- ### Build music only
- Since music is contained in a `monorepo`, it is possible to target the music app specifically to build:
-
-
- ```bash
- bunx cross-env NODE_ENV=production DEPLOYMENT_TYPE=containerless bun run build --filter music --force
- ```
-
- Music is a self-hostable application, and as such, contains a large range of ways we can deploy it. When deploying locally or in a containerless environment, we have to tell music that we do not intent to look for files in places that would suit containerised environments like docker.
-
-
-
- As we are using specific environments with `cross-env`, [Turborepo](https://turbo.build/repo) does not pick up on a change in environment variables and uses the cached build already generated. The `--force` flag ensures that all builds are clean.
-
-
-
-
- ### Run music
- ```bash
- bunx cross-env NODE_ENV=production DEVELOPMENT_TYPE=containerless bun run start --filter music
- ```
-
- This will start the production server on port 3000, to change the port, add the `--port` flag
-
-
\ No newline at end of file
diff --git a/apps/docs/content/docs/getting-started/meta.json b/apps/docs/content/docs/getting-started/meta.json
deleted file mode 100644
index 945dcc9e..00000000
--- a/apps/docs/content/docs/getting-started/meta.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "pages": [
- "truenas-scale",
- "docker",
- "locally"
- ]
-}
\ No newline at end of file
diff --git a/apps/docs/content/docs/getting-started/truenas-scale.mdx b/apps/docs/content/docs/getting-started/truenas-scale.mdx
deleted file mode 100644
index e2c5b2cb..00000000
--- a/apps/docs/content/docs/getting-started/truenas-scale.mdx
+++ /dev/null
@@ -1,248 +0,0 @@
----
-title: TrueNas Scale
-description: Steps to install ParsonLabs Music with TrueNas Scale
----
-
-In order to run ParsonLabs Music, we are going to run it in two docker containers. As such, we will need to use [`custom-app` from the premium train](https://truecharts.org/charts/premium/custom-app/)
-
-## Setting up TrueCharts
-TrueCharts is a community driven extended repository containing many popular and up to date applications that TrueNas lacks, while also providing an easy setup experience.
-
-
-
-[TrueCharts has provided an official guide to installing the chart and can be found here](https://truecharts.org/scale/)
-
-
-While we add support for ParsonLabs Music directly into
-
-
- ### Head to Apps
-
-
-
-
- ### Click Discover Apps on the top right
-
-
-
-
- ### Click Manage Catalog on the top right of the discover page
-
-
-
-
- ### Click Add Catalog
-
-
-
-
- ### After reading the iXsystems notice, click Continue and enter the required information
-
-
-
-
- ### Click save and allow SCALE to refresh its catalog with TrueCharts (this may take a few minutes)
-
-
-
-## Setting up ParsonLabs Music on TrueNas Scale
-To setup up, we need
-
-### Using custom-app
-The [`custom-app`](https://truecharts.org/charts/premium/custom-app/) application from the TrueCharts premium train allows any deployment to a TrueCharts-like environment. Since music has [Docker containers on the Github registry](https://github.com/WillKirkmanM/music/packages), we are able to deploy music on TrueNas.
-
-
-
- #### Create a custom-app instance
- Head to the `Discover Apps` section in the `Apps` section and search for `custom-app` and then click install.
-
-
-
- #### Set Container Repository and Container Tag
- Set the `Container Repository` to `ghcr.io/willkirkmanm/music` and the `Container Tag` to `latest`
-
-
-
- #### Disable WebUI Portal
- It is recommended but not necessary to uncheck the `Enable WebUI Portal` button in the `Portal Configuration` section of `General`
-
-
-
- #### Set Environment Variables
- In the `Extra Environment Variables` section, it is required to have these environment variables set:
-
-
-
-
- Environment Variable
-
-
-
- Value
-
-
-
- Description
-
-
-
-
-
-
-
- `BACKEND_PORT`
-
-
- 3001
-
-
- This is the port that music-backend will run on.
-
-
-
-
-
- `NEXT_AUTH_SECRET`
-
-
- xVcsAVKpo9yOTxbq/JCmHHGYP4MgtIrt9l74s3937xg=
-
-
- `openssl rand -base64 32` or you can visit [Generate Plus](https://generate.plus/en/base64) and set the length to 32
-
-
-
-
-
- `NEXTAUTH_URL`
-
-
- YOUR DOMAIN
-
-
- This will control how the authentication redirects the user. This will need to either the domain name of the music app with `ingress` as a reverse proxy or the local ip address.
-
-
-
-
-
- `DEPLOYMENT_TYPE`
-
-
- docker
-
-
- This controls how the configuration for music is obtained. For `containerless` environments, the `music.json` configuration is searched in the `/app/Config` instead of the root filesystem.
-
-
-
-
-
-
-
-
- ### Set Ports to Listen On
-
-
-
-
- Port Configuration Name
-
-
- Value
-
-
-
-
-
-
-
- **Port Type**
-
-
- http
-
-
-
-
-
- **Target Port**
-
-
- 3000
-
-
-
-
-
- **Port**
-
-
- This is the port you will be accessing music on.
-
-
-
-
-
-
-
- ### Accessing Filesystem (Persistance)
- For music to function properly, these three volumes need to be mapped to the host system:
-
-
-
-
- Storage Name
-
-
- Mount Path
-
-
- Description
-
-
-
-
-
-
-
- Music path
-
-
- /Music
-
-
- This is the directory where your music is stored.
-
-
-
-
-
- Missing Cover Art
-
-
- /missing_cover_art
-
-
- This is the directory where the missing cover art for albums will be downloaded to.
-
-
-
-
- Config
-
-
- /Config
-
-
- This is the directory where `music.json` will be stored and accessed.
-
-
-
-
-
-
-
- ### Deploy
-
-
\ No newline at end of file
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
deleted file mode 100644
index 7488718e..00000000
--- a/apps/docs/content/docs/index.mdx
+++ /dev/null
@@ -1,23 +0,0 @@
----
-title: Introduction
-description: Welcome to the ParsonLabs Music Documentation
----
-
-Welcome to the ParsonLabs Music Documentation, these pages serve to contain guides in setting up, navigating and contributing the repository, along with the philosophies behind design choices.
-
-## Getting Started
-
-
-
-
-
-
-
-## Contributing
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
deleted file mode 100644
index 75923823..00000000
--- a/apps/docs/content/docs/meta.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "title": "ParsonLabs Music",
- "root": true,
- "pages": [
- "index",
- "getting-started",
- "contributing"
- ]
-}
\ No newline at end of file
diff --git a/apps/docs/mdx-components.tsx b/apps/docs/mdx-components.tsx
deleted file mode 100644
index 13136885..00000000
--- a/apps/docs/mdx-components.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { MDXComponents } from 'mdx/types';
-import defaultComponents from 'fumadocs-ui/mdx';
-import { Callout } from "fumadocs-ui/components/callout"
-import { Folder, Files, File } from "fumadocs-ui/components/files"
-import { ImageZoom } from 'fumadocs-ui/components/image-zoom';
-import { Steps, Step } from "fumadocs-ui/components/steps"
-import { Table, TableHeader, TableBody, TableHead, TableCell, TableRow } from "@music/ui/components/table"
-import { SquareArrowOutUpRight, CirclePlus } from "lucide-react"
-
-export function useMDXComponents(components: MDXComponents): MDXComponents {
- return {
- ...defaultComponents,
- ...components,
- Callout,
- ImageZoom,
- Steps,
- Step,
- Table,
- TableHeader,
- TableBody,
- TableHead,
- TableCell,
- TableRow,
- Folder,
- Files,
- File,
- SquareArrowOutUpRight,
- CirclePlus
- };
-}
diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts
deleted file mode 100644
index 4f11a03d..00000000
--- a/apps/docs/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs
deleted file mode 100644
index 3e44fbc3..00000000
--- a/apps/docs/next.config.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-import createMDX from 'fumadocs-mdx/config';
-
-const withMDX = createMDX();
-
-/** @type {import('next').NextConfig} */
-const config = {
- reactStrictMode: true,
-};
-
-export default withMDX(config);
diff --git a/apps/docs/package.json b/apps/docs/package.json
deleted file mode 100644
index a9d5f1e8..00000000
--- a/apps/docs/package.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "name": "docs",
- "version": "0.0.0",
- "private": true,
- "scripts": {
- "build": "next build",
- "dev": "next dev",
- "start": "next start"
- },
- "dependencies": {
- "next": "^14.2.3",
- "fumadocs-ui": "^11.1.1",
- "lucide-react": "0.378.0",
- "fumadocs-core": "^11.1.1",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "fumadocs-mdx": "^8.2.19"
- },
- "devDependencies": {
- "@types/react": "^18.3.2",
- "@types/react-dom": "^18.3.0",
- "@music/ui": "*",
- "typescript": "^5.4.5",
- "@types/mdx": "^2.0.13",
- "autoprefixer": "^10.4.19",
- "postcss": "^8.4.38",
- "tailwindcss": "^3.4.3"
- }
-}
\ No newline at end of file
diff --git a/apps/docs/postcss.config.js b/apps/docs/postcss.config.js
deleted file mode 100644
index 12a703d9..00000000
--- a/apps/docs/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-module.exports = {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-};
diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js
deleted file mode 100644
index a925dcbd..00000000
--- a/apps/docs/tailwind.config.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { createPreset } from 'fumadocs-ui/tailwind-plugin';
-
-/** @type {import('tailwindcss').Config} */
-export default {
- darkMode: "class",
- presets: [createPreset()],
- content: [
- '../../node_modules/fumadocs-ui/dist/**/*.js',
- './components/**/*.{ts,tsx}',
- './app/**/*.{ts,tsx}',
- './content/**/*.{md,mdx}',
- './mdx-components.{ts,tsx}',
- './node_modules/fumadocs-ui/dist/**/*.js',
- ],
-};
diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json
deleted file mode 100644
index f90b1421..00000000
--- a/apps/docs/tsconfig.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "compilerOptions": {
- "baseUrl": ".",
- "target": "ESNext",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "forceConsistentCasingInFileNames": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "Bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "preserve",
- "incremental": true,
- "paths": {
- "@/*": ["./*"]
- },
- "plugins": [
- {
- "name": "next"
- }
- ]
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["../../node_modules"]
-}
diff --git a/apps/landing/.gitignore b/apps/landing/.gitignore
deleted file mode 100644
index 016b59ea..00000000
--- a/apps/landing/.gitignore
+++ /dev/null
@@ -1,24 +0,0 @@
-# build output
-dist/
-
-# generated types
-.astro/
-
-# dependencies
-node_modules/
-
-# logs
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-
-# environment variables
-.env
-.env.production
-
-# macOS-specific files
-.DS_Store
-
-# jetbrains setting folder
-.idea/
diff --git a/apps/landing/.vscode/extensions.json b/apps/landing/.vscode/extensions.json
deleted file mode 100644
index 22a15055..00000000
--- a/apps/landing/.vscode/extensions.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "recommendations": ["astro-build.astro-vscode"],
- "unwantedRecommendations": []
-}
diff --git a/apps/landing/.vscode/launch.json b/apps/landing/.vscode/launch.json
deleted file mode 100644
index d6422097..00000000
--- a/apps/landing/.vscode/launch.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "version": "0.2.0",
- "configurations": [
- {
- "command": "./node_modules/.bin/astro dev",
- "name": "Development server",
- "request": "launch",
- "type": "node-terminal"
- }
- ]
-}
diff --git a/apps/landing/astro.config.mjs b/apps/landing/astro.config.mjs
deleted file mode 100644
index 476e71ab..00000000
--- a/apps/landing/astro.config.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-import { defineConfig } from 'astro/config';
-import tailwind from '@astrojs/tailwind';
-import react from "@astrojs/react";
-
-// https://astro.build/config
-export default defineConfig({
- integrations: [tailwind({
- applyBaseStyles: false
- }), react()]
-});
\ No newline at end of file
diff --git a/apps/landing/package.json b/apps/landing/package.json
deleted file mode 100644
index f5f21161..00000000
--- a/apps/landing/package.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "name": "landing",
- "version": "0.0.1",
- "private": true,
- "scripts": {
- "dev": "astro dev",
- "start": "astro dev",
- "build": "astro check && astro build",
- "preview": "astro preview",
- "astro": "astro"
- },
- "dependencies": {
- "@astrojs/check": "^0.5.10",
- "@astrojs/react": "^3.3.1",
- "@astrojs/tailwind": "latest",
- "@music/ui": "*",
- "@radix-ui/react-icons": "^1.3.0",
- "@types/react": "^18.3.1",
- "@types/react-dom": "^18.3.0",
- "astro": "^4.7.0",
- "astro-font": "^0.0.80",
- "autoprefixer": "latest",
- "postcss": "latest",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "tailwindcss": "latest",
- "typescript": "^5.4.5"
- }
-}
diff --git a/apps/landing/postcss.config.js b/apps/landing/postcss.config.js
deleted file mode 100644
index 2734726a..00000000
--- a/apps/landing/postcss.config.js
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = require('@music/ui/postcss.config');
\ No newline at end of file
diff --git a/apps/landing/public/favicon.svg b/apps/landing/public/favicon.svg
deleted file mode 100644
index f157bd1c..00000000
--- a/apps/landing/public/favicon.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
diff --git a/apps/landing/src/components/Navbar.tsx b/apps/landing/src/components/Navbar.tsx
deleted file mode 100644
index 58318217..00000000
--- a/apps/landing/src/components/Navbar.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import * as React from "react"
-
-import { cn } from "@music/ui/lib/utils"
-import {
- NavigationMenu,
- NavigationMenuContent,
- NavigationMenuItem,
- NavigationMenuLink,
- NavigationMenuList,
- NavigationMenuTrigger,
- navigationMenuTriggerStyle,
-} from "@music/ui/components/navigation-menu"
-
-export default function Navbar() {
- return (
-
-
-
- Downloads
-
-
-
- Download the official server for Windows.
-
-
-
- Download the official server for Linux.
-
-
-
-
-
-
-
- Documentation
-
-
-
-
- )
-}
-
-const ListItem = React.forwardRef<
- React.ElementRef<"a">,
- React.ComponentPropsWithoutRef<"a">
->(({ className, title, children, ...props }, ref) => {
- return (
-
-
-
- {title}
-
- {children}
-
-
-
-
- )
-})
-ListItem.displayName = "ListItem"
diff --git a/apps/landing/src/env.d.ts b/apps/landing/src/env.d.ts
deleted file mode 100644
index acef35f1..00000000
--- a/apps/landing/src/env.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-///
-///
diff --git a/apps/landing/src/layout/layout.astro b/apps/landing/src/layout/layout.astro
deleted file mode 100644
index e11f7046..00000000
--- a/apps/landing/src/layout/layout.astro
+++ /dev/null
@@ -1,22 +0,0 @@
----
-import "@music/ui/globals.css"
-import { AstroFont } from "astro-font";
----
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro
deleted file mode 100644
index 67ee9663..00000000
--- a/apps/landing/src/pages/index.astro
+++ /dev/null
@@ -1,28 +0,0 @@
----
-import "@music/ui/globals.css"
-import { Button } from "@music/ui/button"
-
-import Layout from "../layout/layout.astro"
-import Navbar from "../components/Navbar"
-import { GitHubLogoIcon } from "@radix-ui/react-icons"
----
-
-
-
-
-
-
-
ParsonLabs Music
-
Own your Music
-
Need something to Listen to?
-
-
\ No newline at end of file
diff --git a/apps/landing/tailwind.config.js b/apps/landing/tailwind.config.js
deleted file mode 100644
index 5049055c..00000000
--- a/apps/landing/tailwind.config.js
+++ /dev/null
@@ -1,2 +0,0 @@
-
-export * from "@music/ui/tailwind.config";
\ No newline at end of file
diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json
deleted file mode 100644
index 032ad645..00000000
--- a/apps/landing/tsconfig.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": "astro/tsconfigs/strict",
- "compilerOptions": {
- "jsx": "react-jsx",
- "jsxImportSource": "react"
- }
-}
\ No newline at end of file
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
new file mode 100644
index 00000000..a874b823
--- /dev/null
+++ b/apps/mobile/.gitignore
@@ -0,0 +1,35 @@
+node_modules/
+
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+.metro-health-check*
+
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+.DS_Store
+*.pem
+
+.env*.local
+
+*.tsbuildinfo
+
+example
+
+/ios
+/android
+
+/modules/*/android/.gradle/
+/modules/*/android/build/
diff --git a/apps/mobile/.vscode/extensions.json b/apps/mobile/.vscode/extensions.json
new file mode 100644
index 00000000..b7ed8377
--- /dev/null
+++ b/apps/mobile/.vscode/extensions.json
@@ -0,0 +1 @@
+{ "recommendations": ["expo.vscode-expo-tools"] }
diff --git a/apps/mobile/.vscode/settings.json b/apps/mobile/.vscode/settings.json
new file mode 100644
index 00000000..e2798e42
--- /dev/null
+++ b/apps/mobile/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "explicit",
+ "source.organizeImports": "explicit",
+ "source.sortMembers": "explicit"
+ }
+}
diff --git a/apps/mobile/LICENSE b/apps/mobile/LICENSE
new file mode 100644
index 00000000..8c4e057d
--- /dev/null
+++ b/apps/mobile/LICENSE
@@ -0,0 +1,25 @@
+This notice applies only to portions derived from the Expo project template.
+Parson's original source remains licensed under GPL-3.0-only by the repository-root
+LICENSE.
+
+The MIT License (MIT)
+
+Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
new file mode 100644
index 00000000..26d9faa7
--- /dev/null
+++ b/apps/mobile/README.md
@@ -0,0 +1,19 @@
+# Parson Mobile
+
+Expo client for Parson servers on the local network.
+
+## Development
+
+```bash
+bun install
+bun run start
+bun run android
+bun run ios
+bun run web
+bun run lint
+bun run type-check
+```
+
+## Licensing
+
+GPL-3.0-only. See the root and local `LICENSE` files.
diff --git a/apps/mobile/THIRD_PARTY_NOTICES.md b/apps/mobile/THIRD_PARTY_NOTICES.md
new file mode 100644
index 00000000..e97000c5
--- /dev/null
+++ b/apps/mobile/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,33 @@
+# Third-party notices
+
+Parson Mobile uses `react-native-audio-api` 0.13.2, distributed under the MIT
+License:
+
+> MIT License
+>
+> Copyright (c) 2024 Software Mansion
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of this software and associated documentation files (the "Software"), to deal
+> in the Software without restriction, including without limitation the rights
+> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+> copies of the Software, and to permit persons to whom the Software is
+> furnished to do so, subject to the following conditions:
+>
+> The above copyright notice and this permission notice shall be included in all
+> copies or substantial portions of the Software.
+>
+> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+> SOFTWARE.
+
+The dependency also identifies WebKit-derived BSD-licensed source and FFmpeg
+under LGPL 2.1 or later. Its upstream notices, license texts, source references,
+and relinking instructions are maintained at:
+
+- https://github.com/software-mansion/react-native-audio-api/blob/0.13.2/THIRD_PARTY.md
+- https://github.com/software-mansion/react-native-audio-api/tree/0.13.2/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
new file mode 100644
index 00000000..b5bea2ad
--- /dev/null
+++ b/apps/mobile/app.json
@@ -0,0 +1,96 @@
+{
+ "expo": {
+ "name": "Parson",
+ "slug": "parson",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/parson-icon.png",
+ "scheme": "parson",
+ "userInterfaceStyle": "dark",
+ "backgroundColor": "#000000",
+ "ios": {
+ "bundleIdentifier": "com.parsonlabs.parson",
+ "supportsTablet": true,
+ "infoPlist": {
+ "NSLocalNetworkUsageDescription": "Parson finds music libraries running on your local network.",
+ "NSBonjourServices": ["_parson._tcp"]
+ }
+ },
+ "android": {
+ "package": "com.parsonlabs.parson",
+ "adaptiveIcon": {
+ "backgroundColor": "#000000",
+ "foregroundImage": "./assets/images/parson-maskable.png",
+ "monochromeImage": "./assets/images/parson-monochrome.png"
+ },
+ "predictiveBackGestureEnabled": true,
+ "permissions": [
+ "android.permission.INTERNET",
+ "android.permission.ACCESS_NETWORK_STATE",
+ "android.permission.CHANGE_WIFI_MULTICAST_STATE",
+ "android.permission.POST_NOTIFICATIONS"
+ ],
+ "blockedPermissions": [
+ "android.permission.READ_EXTERNAL_STORAGE",
+ "android.permission.RECORD_AUDIO",
+ "android.permission.SYSTEM_ALERT_WINDOW",
+ "android.permission.WRITE_EXTERNAL_STORAGE"
+ ]
+ },
+ "web": {
+ "output": "static",
+ "favicon": "./assets/images/parson-icon.png"
+ },
+ "plugins": [
+ "./plugins/with-fast-player-transitions",
+ "expo-router",
+ [
+ "expo-navigation-bar",
+ {
+ "style": "light",
+ "hidden": false,
+ "enforceContrast": false
+ }
+ ],
+ [
+ "react-native-audio-api",
+ {
+ "iosBackgroundMode": true,
+ "androidForegroundService": false,
+ "androidFSTypes": ["mediaPlayback"]
+ }
+ ],
+ [
+ "expo-build-properties",
+ {
+ "android": {
+ "usesCleartextTraffic": true,
+ "enableMinifyInReleaseBuilds": true,
+ "enableShrinkResourcesInReleaseBuilds": true
+ }
+ }
+ ],
+ "expo-secure-store",
+ [
+ "expo-splash-screen",
+ {
+ "backgroundColor": "#000000",
+ "image": "./assets/images/parson-icon.png",
+ "imageWidth": 112
+ }
+ ],
+ [
+ "expo-audio",
+ {
+ "recordAudioAndroid": false,
+ "microphonePermission": false,
+ "enableBackgroundPlayback": true
+ }
+ ]
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "reactCompiler": true
+ }
+ }
+}
diff --git a/apps/mobile/assets/images/parson-icon.png b/apps/mobile/assets/images/parson-icon.png
new file mode 100644
index 00000000..593c0a00
Binary files /dev/null and b/apps/mobile/assets/images/parson-icon.png differ
diff --git a/apps/mobile/assets/images/parson-maskable.png b/apps/mobile/assets/images/parson-maskable.png
new file mode 100644
index 00000000..92fc6ca0
Binary files /dev/null and b/apps/mobile/assets/images/parson-maskable.png differ
diff --git a/apps/mobile/assets/images/parson-monochrome.png b/apps/mobile/assets/images/parson-monochrome.png
new file mode 100644
index 00000000..593c0a00
Binary files /dev/null and b/apps/mobile/assets/images/parson-monochrome.png differ
diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js
new file mode 100644
index 00000000..4566278e
--- /dev/null
+++ b/apps/mobile/eslint.config.js
@@ -0,0 +1,9 @@
+const { defineConfig } = require("eslint/config");
+const expoConfig = require("eslint-config-expo/flat");
+
+module.exports = defineConfig([
+ expoConfig,
+ {
+ ignores: ["dist/*"],
+ },
+]);
diff --git a/apps/mobile/modules/parson-discovery/LICENSE b/apps/mobile/modules/parson-discovery/LICENSE
new file mode 100644
index 00000000..8fb9e6b6
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/LICENSE
@@ -0,0 +1,25 @@
+This notice applies only to portions derived from the Expo native-module template.
+Parson's original source remains licensed under GPL-3.0-only by the repository-root
+LICENSE.
+
+The MIT License (MIT)
+
+Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/mobile/modules/parson-discovery/android/build.gradle b/apps/mobile/modules/parson-discovery/android/build.gradle
new file mode 100644
index 00000000..0968f2b5
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/android/build.gradle
@@ -0,0 +1,18 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'com.parsonlabs.discovery'
+version = '0.1.0'
+
+android {
+ namespace "com.parsonlabs.discovery"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
diff --git a/apps/mobile/modules/parson-discovery/android/src/main/AndroidManifest.xml b/apps/mobile/modules/parson-discovery/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..bdae66c8
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/apps/mobile/modules/parson-discovery/android/src/main/java/com/parsonlabs/discovery/ParsonDiscoveryModule.kt b/apps/mobile/modules/parson-discovery/android/src/main/java/com/parsonlabs/discovery/ParsonDiscoveryModule.kt
new file mode 100644
index 00000000..7ef72460
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/android/src/main/java/com/parsonlabs/discovery/ParsonDiscoveryModule.kt
@@ -0,0 +1,61 @@
+package com.parsonlabs.discovery
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.net.wifi.WifiManager
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ParsonDiscoveryModule : Module() {
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+ private var multicastLock: WifiManager.MulticastLock? = null
+ private val resolved = mutableSetOf()
+ private val context: Context get() = requireNotNull(appContext.reactContext)
+
+ private fun startDiscovery() {
+ if (discoveryListener != null) return
+ val nsd = context.getSystemService(Context.NSD_SERVICE) as NsdManager
+ val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ multicastLock = wifi.createMulticastLock("parson-discovery").apply { setReferenceCounted(false); acquire() }
+ resolved.clear()
+ val listener = object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(type: String) = Unit
+ override fun onDiscoveryStopped(type: String) = Unit
+ override fun onStartDiscoveryFailed(type: String, code: Int) { stopDiscovery() }
+ override fun onStopDiscoveryFailed(type: String, code: Int) { stopDiscovery() }
+ override fun onServiceLost(service: NsdServiceInfo) { resolved.remove(service.serviceName) }
+ override fun onServiceFound(service: NsdServiceInfo) {
+ if (!service.serviceType.startsWith("_parson._tcp") || !resolved.add(service.serviceName)) return
+ @Suppress("DEPRECATION")
+ nsd.resolveService(service, object : NsdManager.ResolveListener {
+ override fun onResolveFailed(info: NsdServiceInfo, code: Int) { resolved.remove(info.serviceName) }
+ override fun onServiceResolved(info: NsdServiceInfo) {
+ val host = info.host?.hostAddress ?: info.host?.hostName ?: return
+ sendEvent("onService", mapOf("name" to info.serviceName, "host" to host, "port" to info.port))
+ }
+ })
+ }
+ }
+ discoveryListener = listener
+ nsd.discoverServices("_parson._tcp.", NsdManager.PROTOCOL_DNS_SD, listener)
+ }
+
+ private fun stopDiscovery() {
+ val listener = discoveryListener ?: return
+ discoveryListener = null
+ runCatching { (context.getSystemService(Context.NSD_SERVICE) as NsdManager).stopServiceDiscovery(listener) }
+ runCatching { multicastLock?.release() }
+ multicastLock = null
+ }
+
+ override fun definition() = ModuleDefinition {
+ Name("ParsonDiscovery")
+ Events("onService")
+ Function("start") { startDiscovery() }
+ Function("stop") { stopDiscovery() }
+ OnStartObserving("onService") { startDiscovery() }
+ OnStopObserving("onService") { stopDiscovery() }
+ OnDestroy { stopDiscovery() }
+ }
+}
diff --git a/apps/mobile/modules/parson-discovery/expo-module.config.json b/apps/mobile/modules/parson-discovery/expo-module.config.json
new file mode 100644
index 00000000..537cfc0a
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["android", "apple"],
+ "apple": {
+ "modules": ["ParsonDiscoveryModule"]
+ },
+ "android": {
+ "modules": ["com.parsonlabs.discovery.ParsonDiscoveryModule"]
+ }
+}
diff --git a/apps/mobile/modules/parson-discovery/index.ts b/apps/mobile/modules/parson-discovery/index.ts
new file mode 100644
index 00000000..5e52744e
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./src/ParsonDiscoveryModule";
+export * from "./src/ParsonDiscovery.types";
diff --git a/apps/mobile/modules/parson-discovery/ios/ParsonDiscovery.podspec b/apps/mobile/modules/parson-discovery/ios/ParsonDiscovery.podspec
new file mode 100644
index 00000000..67a170fb
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/ios/ParsonDiscovery.podspec
@@ -0,0 +1,22 @@
+Pod::Spec.new do |s|
+ s.name = 'ParsonDiscovery'
+ s.version = '1.0.0'
+ s.summary = 'A sample project summary'
+ s.description = 'A sample project description'
+ s.author = ''
+ s.homepage = 'https://docs.expo.dev/modules/'
+ s.platforms = {
+ :ios => '16.4',
+ :tvos => '16.4'
+ }
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ }
+
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/apps/mobile/modules/parson-discovery/ios/ParsonDiscoveryModule.swift b/apps/mobile/modules/parson-discovery/ios/ParsonDiscoveryModule.swift
new file mode 100644
index 00000000..088aaf60
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/ios/ParsonDiscoveryModule.swift
@@ -0,0 +1,32 @@
+import ExpoModulesCore
+import Foundation
+
+private final class ParsonServiceBrowser: NSObject, NetServiceBrowserDelegate, NetServiceDelegate {
+ let browser = NetServiceBrowser()
+ var emit: ((String, String, Int) -> Void)?
+ override init() { super.init(); browser.delegate = self }
+ func start() { browser.searchForServices(ofType: "_parson._tcp.", inDomain: "local.") }
+ func stop() { browser.stop() }
+ func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
+ service.delegate = self
+ service.resolve(withTimeout: 5)
+ }
+ func netServiceDidResolveAddress(_ sender: NetService) {
+ guard let hostname = sender.hostName else { return }
+ emit?(sender.name, hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname, sender.port)
+ }
+}
+
+public class ParsonDiscoveryModule: Module {
+ private let discovery = ParsonServiceBrowser()
+ public func definition() -> ModuleDefinition {
+ Name("ParsonDiscovery")
+ Events("onService")
+ OnCreate { self.discovery.emit = { [weak self] name, host, port in self?.sendEvent("onService", ["name": name, "host": host, "port": port]) } }
+ Function("start") { self.discovery.start() }
+ Function("stop") { self.discovery.stop() }
+ OnStartObserving("onService") { self.discovery.start() }
+ OnStopObserving("onService") { self.discovery.stop() }
+ OnDestroy { self.discovery.stop() }
+ }
+}
diff --git a/apps/mobile/modules/parson-discovery/src/ParsonDiscovery.types.ts b/apps/mobile/modules/parson-discovery/src/ParsonDiscovery.types.ts
new file mode 100644
index 00000000..edfa75e9
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/src/ParsonDiscovery.types.ts
@@ -0,0 +1,9 @@
+export type ParsonDiscoveryModuleEvents = {
+ onService: (params: DiscoveredService) => void;
+};
+
+export type DiscoveredService = {
+ name: string;
+ host: string;
+ port: number;
+};
diff --git a/apps/mobile/modules/parson-discovery/src/ParsonDiscoveryModule.ts b/apps/mobile/modules/parson-discovery/src/ParsonDiscoveryModule.ts
new file mode 100644
index 00000000..99e1e8c5
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/src/ParsonDiscoveryModule.ts
@@ -0,0 +1,10 @@
+import { NativeModule, requireNativeModule } from "expo";
+
+import { ParsonDiscoveryModuleEvents } from "./ParsonDiscovery.types";
+
+declare class ParsonDiscoveryModule extends NativeModule {
+ start(): void;
+ stop(): void;
+}
+
+export default requireNativeModule("ParsonDiscovery");
diff --git a/apps/mobile/modules/parson-discovery/src/ParsonDiscoveryModule.web.ts b/apps/mobile/modules/parson-discovery/src/ParsonDiscoveryModule.web.ts
new file mode 100644
index 00000000..666738f0
--- /dev/null
+++ b/apps/mobile/modules/parson-discovery/src/ParsonDiscoveryModule.web.ts
@@ -0,0 +1,13 @@
+import { registerWebModule, NativeModule } from "expo";
+
+import { ParsonDiscoveryModuleEvents } from "./ParsonDiscovery.types";
+
+class ParsonDiscoveryModule extends NativeModule {
+ start() {}
+ stop() {}
+}
+
+export default registerWebModule(
+ ParsonDiscoveryModule,
+ "ParsonDiscoveryModule",
+);
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000..f1872e53
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "parson-mobile",
+ "main": "expo-router/entry",
+ "version": "1.0.0",
+ "license": "GPL-3.0-only",
+ "dependencies": {
+ "@expo/ui": "~57.0.7",
+ "@parson/music-sdk": "workspace:*",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-native-community/slider": "5.2.0",
+ "@shopify/flash-list": "2.0.2",
+ "@tanstack/react-query": "^5.101.2",
+ "expo": "~57.0.7",
+ "expo-audio": "~57.0.2",
+ "expo-blur": "~57.0.2",
+ "expo-build-properties": "~57.0.6",
+ "expo-constants": "~57.0.6",
+ "expo-device": "~57.0.1",
+ "expo-file-system": "~57.0.1",
+ "expo-font": "~57.0.1",
+ "expo-glass-effect": "~57.0.1",
+ "expo-haptics": "~57.0.1",
+ "expo-image": "~57.0.1",
+ "expo-linear-gradient": "~57.0.1",
+ "expo-linking": "~57.0.3",
+ "expo-navigation-bar": "~57.0.2",
+ "expo-router": "~57.0.7",
+ "expo-secure-store": "~57.0.1",
+ "expo-splash-screen": "~57.0.4",
+ "expo-status-bar": "~57.0.1",
+ "expo-symbols": "~57.0.1",
+ "expo-system-ui": "~57.0.1",
+ "expo-web-browser": "~57.0.1",
+ "lucide-react-native": "^1.25.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "react-native": "0.86.0",
+ "react-native-audio-api": "^0.13.2",
+ "react-native-gesture-handler": "~2.32.0",
+ "react-native-reanimated": "4.5.0",
+ "react-native-safe-area-context": "~5.7.0",
+ "react-native-screens": "4.25.2",
+ "react-native-svg": "15.15.4",
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.10.0"
+ },
+ "devDependencies": {
+ "@types/react": "~19.2.2",
+ "eslint": "^9.0.0",
+ "eslint-config-expo": "~57.0.0",
+ "typescript": "~6.0.3"
+ },
+ "scripts": {
+ "start": "expo start",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "web": "expo start --web",
+ "lint": "expo lint",
+ "type-check": "tsc --noEmit",
+ "prebuild:android": "expo prebuild --platform android --clean",
+ "build:android": "expo run:android --variant release"
+ },
+ "private": true
+}
diff --git a/apps/mobile/patches/react-native-audio-api@0.13.2.patch b/apps/mobile/patches/react-native-audio-api@0.13.2.patch
new file mode 100644
index 00000000..53be8d9b
--- /dev/null
+++ b/apps/mobile/patches/react-native-audio-api@0.13.2.patch
@@ -0,0 +1,64 @@
+diff --git a/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp b/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp
+--- a/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp
++++ b/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp
+@@ -1,4 +1,5 @@
+ #include
++#include
+ #include
+ #include
+ #include
+@@ -29,15 +30,19 @@ AudioPlayer::AudioPlayer(
+ context_(context) {}
+
+ bool AudioPlayer::openAudioStream() {
++ const bool useLegacyBuffering = android_get_device_api_level() <= 28;
+ AudioStreamBuilder builder;
+
+- builder.setSharingMode(SharingMode::Exclusive)
++ builder.setAudioApi(useLegacyBuffering ? AudioApi::OpenSLES : AudioApi::Unspecified)
++ ->setSharingMode(useLegacyBuffering ? SharingMode::Shared : SharingMode::Exclusive)
+ ->setFormat(AudioFormat::Float)
+ ->setFormatConversionAllowed(true)
+- ->setPerformanceMode(PerformanceMode::LowLatency)
++ ->setPerformanceMode(
++ useLegacyBuffering ? PerformanceMode::None : PerformanceMode::LowLatency)
+ ->setChannelCount(channelCount_)
+ ->setSampleRateConversionQuality(SampleRateConversionQuality::Medium)
+- ->setFramesPerDataCallback(RENDER_QUANTUM_SIZE)
++ ->setFramesPerDataCallback(
++ useLegacyBuffering ? RENDER_QUANTUM_SIZE * 8 : RENDER_QUANTUM_SIZE)
+ ->setDataCallback(shared_from_this())
+ ->setErrorCallback(shared_from_this())
+ ->setSampleRate(static_cast(sampleRate_));
+diff --git a/src/Audio/Audio.tsx b/src/Audio/Audio.tsx
+--- a/src/Audio/Audio.tsx
++++ b/src/Audio/Audio.tsx
+@@ -211,6 +211,7 @@ const Audio = React.memo(
+ () => ({
+ play,
+ pause,
++ getCurrentTime: () => fileSourceRef.current?.getCurrentTime() ?? 0,
+ seekToTime,
+ setVolume: setVolumeState,
+ setMuted: setMutedState,
+diff --git a/src/Audio/types.ts b/src/Audio/types.ts
+--- a/src/Audio/types.ts
++++ b/src/Audio/types.ts
+@@ -29,6 +29,7 @@ export type AudioTagPlaybackState = 'idle' | 'playing' | 'paused';
+ export interface AudioTagHandle {
+ play: () => void;
+ pause: () => void;
++ getCurrentTime?: () => number;
+ seekToTime: (seconds: number) => void;
+ setVolume: (volume: number) => void;
+ setMuted: (muted: boolean) => void;
+diff --git a/lib/typescript/Audio/types.d.ts b/lib/typescript/Audio/types.d.ts
+--- a/lib/typescript/Audio/types.d.ts
++++ b/lib/typescript/Audio/types.d.ts
+@@ -20,5 +20,6 @@ export interface AudioTagHandle {
+ play: () => void;
+ pause: () => void;
++ getCurrentTime?: () => number;
+ seekToTime: (seconds: number) => void;
+ setVolume: (volume: number) => void;
+ setMuted: (muted: boolean) => void;
diff --git a/apps/mobile/plugins/with-fast-player-transitions.js b/apps/mobile/plugins/with-fast-player-transitions.js
new file mode 100644
index 00000000..3d8664b9
--- /dev/null
+++ b/apps/mobile/plugins/with-fast-player-transitions.js
@@ -0,0 +1,48 @@
+const { withDangerousMod } = require("expo/config-plugins");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+
+const transitionDurationMs = 180;
+
+const animations = {
+ "rns_no_animation_medium.xml": `
+
+`,
+ "rns_slide_in_from_bottom.xml": `
+
+`,
+ "rns_slide_out_to_bottom.xml": `
+
+`,
+};
+
+module.exports = (config) =>
+ withDangerousMod(config, [
+ "android",
+ async (nextConfig) => {
+ const animationDirectory = path.join(
+ nextConfig.modRequest.platformProjectRoot,
+ "app",
+ "src",
+ "main",
+ "res",
+ "anim",
+ );
+ await fs.mkdir(animationDirectory, { recursive: true });
+ await Promise.all(
+ Object.entries(animations).map(([name, contents]) =>
+ fs.writeFile(path.join(animationDirectory, name), contents),
+ ),
+ );
+ return nextConfig;
+ },
+ ]);
diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx
new file mode 100644
index 00000000..4e146300
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/_layout.tsx
@@ -0,0 +1,62 @@
+import { Redirect, Tabs } from "expo-router";
+import { Home, Library, Search, Settings } from "lucide-react-native";
+import { View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { palette } from "@/constants/colors";
+import { useSession } from "@/providers/session-provider";
+
+function icon(Icon: typeof Home) {
+ return function TabBarIcon({
+ color,
+ size,
+ }: {
+ color: unknown;
+ size: number;
+ }) {
+ return ;
+ };
+}
+
+export default function TabsLayout() {
+ const session = useSession();
+ const insets = useSafeAreaInsets();
+ if (session.phase !== "ready" && session.phase !== "offline")
+ return ;
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
new file mode 100644
index 00000000..c36c2517
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -0,0 +1,82 @@
+import { getHomeEssentials } from "@parson/music-sdk";
+import { useQuery } from "@tanstack/react-query";
+import {
+ ActivityIndicator,
+ RefreshControl,
+ ScrollView,
+ StyleSheet,
+ Text,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import {
+ AlbumRail,
+ PageTitle,
+ Screen,
+ SectionTitle,
+ SongRow,
+} from "@/components/music-ui";
+import { layout, palette } from "@/constants/colors";
+
+export default function HomeScreen() {
+ const home = useQuery({
+ queryKey: ["home"],
+ queryFn: getHomeEssentials,
+ });
+ return (
+
+
+ void home.refetch()}
+ />
+ }
+ contentContainerStyle={{
+ paddingBottom: layout.tabBar + layout.miniPlayer + 34,
+ }}
+ >
+ Home
+ {home.isPending ? (
+
+ ) : home.error ? (
+ Could not load your music.
+ ) : home.data ? (
+ <>
+ Recently played
+ {home.data.continue_listening.slice(0, 8).map((song) => (
+
+ ))}
+ Recommended songs
+ {(home.data.recommended.length
+ ? home.data.recommended
+ : home.data.shuffle
+ )
+ .slice(0, 10)
+ .map((song) => (
+
+ ))}
+ Albums you might like
+
+ >
+ ) : null}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ error: { color: palette.secondary, textAlign: "center", marginTop: 80 },
+});
diff --git a/apps/mobile/src/app/(tabs)/library.tsx b/apps/mobile/src/app/(tabs)/library.tsx
new file mode 100644
index 00000000..5b48e3bb
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/library.tsx
@@ -0,0 +1,475 @@
+import {
+ getLibraryCatalog,
+ getLibraryCatalogArtists,
+ getFavoriteSongDetails,
+ getPlaylists,
+ getSongInfos,
+ type LibrarySong,
+ type LibraryAlbum,
+ type LibraryCatalogAlbum,
+ type LibraryCatalogArtist,
+ type PlaylistSummary,
+} from "@parson/music-sdk";
+import {
+ useInfiniteQuery,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { Artwork } from "@/components/artwork";
+import { PageTitle, Screen, SongRow } from "@/components/music-ui";
+import { layout, palette } from "@/constants/colors";
+import { playableCatalogSong } from "@/lib/playable-song";
+import { AlbumActions } from "@/components/album-actions";
+import { ArtistActions } from "@/components/artist-actions";
+import { PlaylistActions } from "@/components/playlist-actions";
+import { PlaylistPicker } from "@/components/playlist-picker";
+import {
+ downloadedRecords,
+ enrichDownloadedSongs,
+ groupDownloadedLibrary,
+ hydrateDownloads,
+ useDownloadsRevision,
+} from "@/lib/downloads";
+import { useSession } from "@/providers/session-provider";
+
+const PAGE_SIZE = 100;
+
+type Section =
+ "albums" | "songs" | "downloads" | "liked" | "artists" | "playlists";
+
+export default function LibraryScreen() {
+ const session = useSession();
+ const client = useQueryClient();
+ const [selectedSection, setSection] = useState(() =>
+ session.phase === "offline" ? "downloads" : "albums",
+ );
+ const section = session.phase === "offline" ? "downloads" : selectedSection;
+ const [selectedAlbum, setSelectedAlbum] =
+ useState(null);
+ const [selectedArtist, setSelectedArtist] =
+ useState(null);
+ const [selectedDownloadedAlbum, setSelectedDownloadedAlbum] =
+ useState(null);
+ const [selectedPlaylist, setSelectedPlaylist] =
+ useState(null);
+ const [creatingPlaylist, setCreatingPlaylist] = useState(false);
+ const downloadsRevision = useDownloadsRevision();
+ const router = useRouter();
+
+ const catalog = useInfiniteQuery({
+ queryKey: ["library", "catalog", section],
+ initialPageParam: 0,
+ queryFn: ({ pageParam }) =>
+ getLibraryCatalog(pageParam, PAGE_SIZE, section as "albums" | "songs"),
+ getNextPageParam: (lastPage, pages) => {
+ const next = pages.length * PAGE_SIZE;
+ const total =
+ section === "albums" ? lastPage.totalAlbums : lastPage.totalSongs;
+ return next < total ? next : undefined;
+ },
+ enabled: section === "albums" || section === "songs",
+ });
+ const artists = useInfiniteQuery({
+ queryKey: ["library", "catalog", "artists"],
+ initialPageParam: 0,
+ queryFn: ({ pageParam }) => getLibraryCatalogArtists(pageParam, PAGE_SIZE),
+ getNextPageParam: (lastPage, pages) =>
+ lastPage.length === PAGE_SIZE ? pages.length * PAGE_SIZE : undefined,
+ enabled: section === "artists",
+ });
+ const liked = useInfiniteQuery({
+ queryKey: ["favorite-song-details"],
+ initialPageParam: undefined as
+ { before_added_at: string; before_song_id: string } | undefined,
+ queryFn: ({ pageParam }) =>
+ getFavoriteSongDetails({ limit: PAGE_SIZE, ...pageParam }),
+ getNextPageParam: (lastPage) => {
+ if (lastPage.length < PAGE_SIZE) return undefined;
+ const last = lastPage.at(-1);
+ return last
+ ? { before_added_at: last.added_at, before_song_id: last.song_id }
+ : undefined;
+ },
+ enabled: section === "liked",
+ });
+ const playlists = useQuery({
+ queryKey: ["playlists"],
+ queryFn: getPlaylists,
+ enabled: section === "playlists",
+ });
+ const downloads = useQuery({
+ queryKey: ["downloads", downloadsRevision],
+ queryFn: async () => {
+ await hydrateDownloads();
+ let records = downloadedRecords();
+ const missing = records
+ .filter((record) => !record.song)
+ .map((record) => record.id);
+ if (missing.length) {
+ try {
+ const resolved = await getSongInfos(missing, false);
+ await enrichDownloadedSongs(Object.values(resolved) as LibrarySong[]);
+ records = downloadedRecords();
+ } catch {
+ // Keep downloaded metadata available offline.
+ }
+ }
+ return records.flatMap((record) => (record.song ? [record.song] : []));
+ },
+ enabled: section === "downloads",
+ });
+ const catalogPages = catalog.data?.pages ?? [];
+ const albumItems = catalogPages.flatMap((page) => page.albums);
+ const songItems = catalogPages.flatMap((page) => page.songs);
+ const playableSongs = songItems.map(playableCatalogSong);
+ const artistItems = artists.data?.pages.flat() ?? [];
+ const likedSongs =
+ liked.data?.pages.flatMap((page) => page.map((item) => item.song)) ?? [];
+ const downloadedItems = groupDownloadedLibrary(downloads.data ?? []);
+ const loading =
+ ((section === "albums" || section === "songs") && catalog.isPending) ||
+ (section === "artists" && artists.isPending) ||
+ (section === "liked" && liked.isPending) ||
+ (section === "downloads" && downloads.isPending) ||
+ (section === "playlists" && playlists.isPending);
+ return (
+
+
+ Library
+ {session.phase === "offline" ? (
+
+ Offline downloads
+
+ Your server is unavailable. Downloaded music still works.
+
+
+ ) : null}
+
+ {(
+ (session.phase === "offline"
+ ? ["downloads"]
+ : [
+ "albums",
+ "songs",
+ "downloads",
+ "liked",
+ "artists",
+ "playlists",
+ ]) as readonly Section[]
+ ).map((value) => (
+ setSection(value)}
+ style={[styles.pill, section === value && styles.activePill]}
+ >
+
+ {value === "liked"
+ ? "Liked Songs"
+ : `${value[0]?.toUpperCase()}${value.slice(1)}`}
+
+
+ ))}
+ {section === "playlists" ? (
+ setCreatingPlaylist(true)}
+ >
+ + New
+
+ ) : null}
+
+ {loading ? (
+
+ ) : (
+
+ {section === "albums" &&
+ albumItems.map((album) => (
+ {
+ if (!selectedAlbum) router.push(`/album/${album.id}`);
+ }}
+ onLongPress={() => setSelectedAlbum(album)}
+ >
+
+
+
+ {album.name}
+
+
+ Album • {album.artistName} • {album.songCount} songs
+
+
+
+ ))}
+ {section === "songs" &&
+ playableSongs.map((song) => (
+
+ ))}
+ {section === "downloads" &&
+ downloadedItems.map((item) =>
+ item.kind === "album" ? (
+ {
+ client.setQueryData(["album", item.album.id], item.album);
+ router.push(`/album/${item.album.id}`);
+ }}
+ onLongPress={() => setSelectedDownloadedAlbum(item.album)}
+ >
+
+
+
+ {item.album.name}
+
+
+ {item.album.artist_object?.name ??
+ item.album.contributing_artists?.[0] ??
+ "Unknown artist"}
+
+
+
+ ) : (
+
+ ),
+ )}
+ {section === "downloads" && downloads.data?.length === 0 ? (
+
+ No downloads yet
+
+ Downloaded songs and albums will appear here for offline
+ playback.
+
+
+ ) : null}
+ {section === "artists" &&
+ artistItems.map((artist) => (
+ {
+ if (!selectedArtist) router.push(`/artist/${artist.id}`);
+ }}
+ onLongPress={() => setSelectedArtist(artist)}
+ >
+
+
+ {artist.name}
+
+
+ Artist • {artist.albumCount} albums
+
+
+
+ ))}
+ {section === "liked" &&
+ likedSongs.map((song) => (
+
+
+
+ ))}
+ {(section === "albums" || section === "songs") &&
+ catalog.hasNextPage ? (
+ void catalog.fetchNextPage()}
+ />
+ ) : null}
+ {section === "artists" && artists.hasNextPage ? (
+ void artists.fetchNextPage()}
+ />
+ ) : null}
+ {section === "liked" && liked.hasNextPage ? (
+ void liked.fetchNextPage()}
+ />
+ ) : null}
+ {section === "playlists" &&
+ playlists.data?.map((playlist) => (
+ {
+ if (!selectedPlaylist)
+ router.push(`/playlist/${playlist.id}`);
+ }}
+ onLongPress={() => setSelectedPlaylist(playlist)}
+ >
+
+
+
+ {playlist.name}
+
+
+ Playlist • {playlist.song_count} songs
+
+
+
+ ))}
+
+ )}
+ {selectedAlbum ? (
+ setSelectedAlbum(null)}
+ />
+ ) : null}
+ {selectedDownloadedAlbum ? (
+ setSelectedDownloadedAlbum(null)}
+ />
+ ) : null}
+ {selectedArtist ? (
+ setSelectedArtist(null)}
+ />
+ ) : null}
+ {selectedPlaylist ? (
+ setSelectedPlaylist(null)}
+ />
+ ) : null}
+ setCreatingPlaylist(false)}
+ />
+
+
+ );
+}
+
+function LoadMore({
+ loading,
+ onPress,
+}: {
+ loading: boolean;
+ onPress: () => void;
+}) {
+ return (
+
+ {loading ? (
+
+ ) : (
+ Load more
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ pillScroller: { flexGrow: 0, flexShrink: 0 },
+ pills: {
+ flexDirection: "row",
+ gap: 8,
+ paddingHorizontal: 20,
+ paddingBottom: 14,
+ },
+ results: { flex: 1 },
+ newPlaylist: { height: 36, justifyContent: "center", paddingHorizontal: 12 },
+ newPlaylistText: { color: "white", fontWeight: "700" },
+ pill: {
+ borderWidth: 1,
+ borderColor: palette.borderStrong,
+ paddingHorizontal: 15,
+ height: 36,
+ borderRadius: 18,
+ justifyContent: "center",
+ },
+ activePill: { backgroundColor: "white" },
+ pillText: { color: "white", fontSize: 13, fontWeight: "700" },
+ activePillText: { color: "black" },
+ row: {
+ minHeight: 72,
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 20,
+ gap: 13,
+ },
+ labels: { flex: 1 },
+ name: { color: "white", fontWeight: "700", fontSize: 15 },
+ meta: { color: palette.secondary, fontSize: 13, marginTop: 4 },
+ loadMore: {
+ height: 52,
+ margin: 20,
+ borderRadius: 26,
+ borderWidth: 1,
+ borderColor: palette.borderStrong,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ emptyDownloads: { alignItems: "center", padding: 40, marginTop: 28 },
+ emptyDownloadsTitle: { color: "white", fontSize: 18, fontWeight: "800" },
+ emptyDownloadsText: {
+ color: palette.secondary,
+ lineHeight: 20,
+ marginTop: 8,
+ textAlign: "center",
+ },
+ offlineBanner: {
+ marginHorizontal: 20,
+ marginBottom: 14,
+ padding: 14,
+ borderRadius: 12,
+ backgroundColor: "rgba(255,255,255,.08)",
+ },
+ offlineTitle: { color: "white", fontWeight: "800", fontSize: 14 },
+ offlineText: { color: palette.secondary, fontSize: 13, marginTop: 4 },
+ loadMoreText: { color: "white", fontWeight: "700" },
+});
diff --git a/apps/mobile/src/app/(tabs)/search.tsx b/apps/mobile/src/app/(tabs)/search.tsx
new file mode 100644
index 00000000..39b99eec
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/search.tsx
@@ -0,0 +1,209 @@
+import {
+ getAlbumInfo,
+ searchLibrary,
+ type CombinedItem,
+ type LibraryAlbum,
+} from "@parson/music-sdk";
+import { useQuery } from "@tanstack/react-query";
+import { useRouter } from "expo-router";
+import { Play, Search as SearchIcon, X } from "lucide-react-native";
+import { useDeferredValue, useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { Artwork } from "@/components/artwork";
+import { PageTitle, Screen, SongRow } from "@/components/music-ui";
+import { AlbumActions } from "@/components/album-actions";
+import { ArtistActions } from "@/components/artist-actions";
+import { layout, palette } from "@/constants/colors";
+import { usePlayer } from "@/providers/player-provider";
+import { playableSearchSong } from "@/lib/playable-song";
+
+export default function SearchScreen() {
+ const [query, setQuery] = useState("");
+ const deferred = useDeferredValue(query.trim());
+ const results = useQuery({
+ queryKey: ["search", deferred],
+ queryFn: () => searchLibrary(deferred),
+ enabled: deferred.length > 0,
+ staleTime: 60_000,
+ });
+ const uniqueResults = useMemo(
+ () =>
+ Array.from(
+ new Map(
+ (results.data ?? []).map(
+ (item) => [`${item.item_type}-${item.id}`, item] as const,
+ ),
+ ).values(),
+ ),
+ [results.data],
+ );
+ return (
+
+
+ Search
+
+
+
+ {query ? (
+ setQuery("")}>
+
+
+ ) : null}
+
+ {results.isFetching ? (
+
+ ) : deferred && results.data?.length === 0 ? (
+ No results for “{deferred}”
+ ) : null}
+
+ {uniqueResults.map((item, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+function SearchResult({ item }: { item: CombinedItem }) {
+ const router = useRouter();
+ const player = usePlayer();
+ const type = item.item_type.toLowerCase();
+ const [menu, setMenu] = useState(false);
+ const [playing, setPlaying] = useState(false);
+ const path = item.album_object?.cover_url;
+ const open = () => {
+ if (menu) return;
+ if (type.includes("album")) router.push(`/album/${item.id}`);
+ else if (type.includes("artist")) router.push(`/artist/${item.id}`);
+ else if (type.includes("song")) {
+ player.playSong(playableSearchSong(item));
+ }
+ };
+ const playAlbum = async () => {
+ if (playing) return;
+ setPlaying(true);
+ try {
+ const album = (await getAlbumInfo(item.id, false)) as LibraryAlbum;
+ if (album.songs[0]) player.playSong(album.songs[0], album.songs);
+ } finally {
+ setPlaying(false);
+ }
+ };
+ if (type.includes("song")) return ;
+ return (
+ <>
+ [styles.result, pressed && { opacity: 0.6 }]}
+ onPress={open}
+ onLongPress={() => setMenu(true)}
+ >
+ {!type.includes("artist") ? (
+
+ ) : null}
+
+
+ {item.name}
+
+
+ {item.item_type}
+ {item.artist_object?.name ? ` • ${item.artist_object.name}` : ""}
+
+
+ {type.includes("album") ? (
+ playing ? (
+
+ ) : (
+ {
+ event.stopPropagation();
+ void playAlbum();
+ }}
+ >
+
+
+ )
+ ) : null}
+
+ {type.includes("album") ? (
+ setMenu(false)}
+ albumId={item.id}
+ artistId={item.artist_object?.id}
+ name={item.name}
+ />
+ ) : type.includes("artist") ? (
+ setMenu(false)}
+ artistId={item.id}
+ name={item.name}
+ />
+ ) : null}
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ searchBox: {
+ marginHorizontal: 20,
+ height: 50,
+ borderRadius: 13,
+ backgroundColor: palette.elevatedStrong,
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 14,
+ gap: 10,
+ marginBottom: 12,
+ },
+ input: { flex: 1, color: "white", fontSize: 16, paddingVertical: 0 },
+ empty: { color: palette.secondary, textAlign: "center", marginTop: 48 },
+ result: {
+ minHeight: 68,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 13,
+ paddingHorizontal: 20,
+ },
+ resultName: { color: "white", fontWeight: "700", fontSize: 15 },
+ resultType: {
+ color: palette.secondary,
+ fontSize: 13,
+ marginTop: 4,
+ textTransform: "capitalize",
+ },
+});
diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx
new file mode 100644
index 00000000..d7889607
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/settings.tsx
@@ -0,0 +1,483 @@
+import {
+ changePassword,
+ indexLibrary,
+ refreshCurrentLibrary,
+ register,
+ setBitrate,
+} from "@parson/music-sdk";
+import { LogOut, Radio, Server } from "lucide-react-native";
+import { useState } from "react";
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { PageTitle, Screen } from "@/components/music-ui";
+import { layout, palette } from "@/constants/colors";
+import { useSession } from "@/providers/session-provider";
+
+type Tab = "account" | "playback" | "server" | "library" | "users";
+const labels: Record = {
+ account: "Account",
+ playback: "Playback",
+ server: "Server",
+ library: "Library",
+ users: "Users",
+};
+
+export default function SettingsScreen() {
+ const session = useSession();
+ const admin = session.claims?.role === "admin";
+ const tabs: Tab[] = admin
+ ? ["account", "playback", "server", "library", "users"]
+ : ["account", "playback", "server"];
+ const [tab, setTab] = useState("account");
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [quality, setQuality] = useState(session.claims?.bitrate ?? 0);
+ const [message, setMessage] = useState("");
+ const [libraryPath, setLibraryPath] = useState("");
+ const [newUsername, setNewUsername] = useState("");
+ const [newUserPassword, setNewUserPassword] = useState("");
+ const [newUserAdmin, setNewUserAdmin] = useState(false);
+ const savePassword = async () => {
+ setMessage("Updating password…");
+ try {
+ await changePassword(currentPassword, newPassword);
+ setCurrentPassword("");
+ setNewPassword("");
+ setMessage("Password updated.");
+ } catch {
+ setMessage("Could not update password.");
+ }
+ };
+ const saveQuality = async (bitrate: number) => {
+ setQuality(bitrate);
+ setMessage("Updating quality…");
+ try {
+ await setBitrate(bitrate);
+ session.updateBitrate(bitrate);
+ setMessage("Quality updated.");
+ } catch {
+ setMessage("Could not update quality.");
+ }
+ };
+ const indexFolder = async () => {
+ if (!libraryPath.trim()) return;
+ setMessage("Indexing…");
+ try {
+ const result = await indexLibrary(libraryPath.trim());
+ setMessage(
+ `Library updated · ${result.report.indexed_files} songs indexed.`,
+ );
+ } catch {
+ setMessage("Could not index this folder.");
+ }
+ };
+ const refreshLibrary = async () => {
+ setMessage("Refreshing…");
+ try {
+ const result = await refreshCurrentLibrary();
+ setMessage(
+ `Refreshed ${result.refreshed.length} library folder${result.refreshed.length === 1 ? "" : "s"}.`,
+ );
+ } catch {
+ setMessage("Current library could not be refreshed.");
+ }
+ };
+ const createUser = async () => {
+ if (newUsername.trim().length < 2 || newUserPassword.length < 8) return;
+ setMessage("Creating…");
+ try {
+ const result = await register({
+ username: newUsername.trim(),
+ password: newUserPassword,
+ role: newUserAdmin ? "admin" : "user",
+ });
+ if (!result.status) {
+ setMessage("Could not create user.");
+ return;
+ }
+ setNewUsername("");
+ setNewUserPassword("");
+ setNewUserAdmin(false);
+ setMessage("User created.");
+ } catch {
+ setMessage("Could not create user.");
+ }
+ };
+ return (
+
+
+
+ Settings
+
+ {tabs.map((value) => (
+ {
+ setTab(value);
+ setMessage("");
+ }}
+ >
+
+ {labels[value]}
+
+
+ ))}
+
+
+ {tab === "account" ? (
+
+ ) : tab === "playback" ? (
+
+ ) : tab === "server" ? (
+
+ ) : tab === "library" ? (
+
+ ) : (
+
+ )}
+ {message ? {message} : null}
+
+
+
+
+ );
+}
+
+function AccountSettings({
+ currentPassword,
+ newPassword,
+ onCurrentPasswordChange,
+ onLogout,
+ onNewPasswordChange,
+ onSave,
+}: {
+ currentPassword: string;
+ newPassword: string;
+ onCurrentPasswordChange: (value: string) => void;
+ onLogout: () => Promise;
+ onNewPasswordChange: (value: string) => void;
+ onSave: () => Promise;
+}) {
+ return (
+ <>
+ Change password
+
+
+ void onSave()}>
+ Update password
+
+
+ void onLogout()}>
+
+ Log out
+
+ >
+ );
+}
+
+const qualityOptions = [
+ [96, "Low · 96 kbps"],
+ [128, "Normal · 128 kbps"],
+ [256, "High · 256 kbps"],
+ [0, "Original quality"],
+] as const;
+
+function PlaybackSettings({
+ onSave,
+ quality,
+}: {
+ onSave: (bitrate: number) => Promise;
+ quality: number;
+}) {
+ return (
+ <>
+ Audio quality
+ {qualityOptions.map(([bitrate, label]) => (
+ void onSave(bitrate)}
+ >
+
+ {quality === bitrate ? "✓ " : ""}
+ {label}
+
+
+ ))}
+ >
+ );
+}
+
+function ServerSettings({
+ offline,
+ libraryName,
+ onChange,
+ onRetry,
+ origin,
+}: {
+ offline: boolean;
+ libraryName: string | null;
+ onChange: () => Promise;
+ onRetry: () => Promise;
+ origin: string | null;
+}) {
+ return (
+ <>
+
+
+
+
+
+ {libraryName}
+
+ {origin}
+
+
+
+ {offline ? (
+ void onRetry()}>
+ Reconnect
+
+ ) : null}
+ void onChange()}>
+
+ Change server
+
+ >
+ );
+}
+
+function LibrarySettings({
+ onIndex,
+ onPathChange,
+ onRefresh,
+ path,
+}: {
+ onIndex: () => Promise;
+ onPathChange: (value: string) => void;
+ onRefresh: () => Promise;
+ path: string;
+}) {
+ return (
+ <>
+ Library
+
+ void onIndex()}>
+ Index folder
+
+ void onRefresh()}>
+
+ Refresh current library
+
+ >
+ );
+}
+
+function UserSettings({
+ admin,
+ onAdminChange,
+ onCreate,
+ onPasswordChange,
+ onUsernameChange,
+ password,
+ username,
+}: {
+ admin: boolean;
+ onAdminChange: (value: boolean) => void;
+ onCreate: () => Promise;
+ onPasswordChange: (value: string) => void;
+ onUsernameChange: (value: string) => void;
+ password: string;
+ username: string;
+}) {
+ return (
+ <>
+ Create user
+
+
+ onAdminChange(!admin)}
+ >
+
+ {admin ? ✓ : null}
+
+ Administrator
+
+ void onCreate()}>
+ Create user
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ tabs: {
+ paddingHorizontal: 20,
+ borderBottomWidth: 1,
+ borderColor: palette.border,
+ },
+ tab: { height: 44, paddingHorizontal: 14, justifyContent: "center" },
+ activeTab: { borderBottomWidth: 2, borderColor: "white" },
+ tabText: { color: palette.muted, fontSize: 14, fontWeight: "600" },
+ activeTabText: { color: "white" },
+ panel: { padding: 20, gap: 12 },
+ heading: { color: "white", fontSize: 19, fontWeight: "800", marginBottom: 5 },
+ input: {
+ height: 50,
+ borderRadius: 10,
+ backgroundColor: palette.elevatedStrong,
+ borderWidth: 1,
+ borderColor: palette.border,
+ color: "white",
+ paddingHorizontal: 15,
+ },
+ primary: {
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: "white",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ primaryText: { color: "black", fontWeight: "800" },
+ divider: { height: 1, backgroundColor: palette.border, marginVertical: 12 },
+ action: {
+ minHeight: 54,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 14,
+ },
+ actionText: { color: "white", fontSize: 15, fontWeight: "600" },
+ quality: {
+ minHeight: 52,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: palette.border,
+ paddingHorizontal: 15,
+ justifyContent: "center",
+ },
+ selected: { borderColor: "white", backgroundColor: "#19191d" },
+ card: {
+ padding: 16,
+ borderRadius: 14,
+ backgroundColor: palette.elevatedStrong,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 13,
+ },
+ icon: {
+ width: 42,
+ height: 42,
+ borderRadius: 12,
+ backgroundColor: "#29292e",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ label: { color: "white", fontWeight: "800" },
+ value: { color: palette.secondary, fontSize: 13, marginTop: 4 },
+ body: { color: palette.secondary, lineHeight: 21 },
+ message: { color: palette.secondary, marginTop: 8 },
+ adminToggle: {
+ minHeight: 48,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ checkbox: {
+ width: 22,
+ height: 22,
+ borderRadius: 5,
+ borderWidth: 1,
+ borderColor: palette.borderStrong,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ checked: { backgroundColor: "white", borderColor: "white" },
+ check: { color: "black", fontWeight: "900" },
+});
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
new file mode 100644
index 00000000..632c5803
--- /dev/null
+++ b/apps/mobile/src/app/_layout.tsx
@@ -0,0 +1,93 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { DarkTheme, Stack, ThemeProvider, useSegments } from "expo-router";
+import * as SplashScreen from "expo-splash-screen";
+import { StatusBar } from "expo-status-bar";
+import { useEffect, useState } from "react";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { palette } from "@/constants/colors";
+import { MiniPlayer } from "@/components/mini-player";
+import { ActionDrawerProvider } from "@/components/action-drawer";
+import { PlayerProvider } from "@/providers/player-provider";
+import { SessionProvider, useSession } from "@/providers/session-provider";
+
+void SplashScreen.preventAutoHideAsync();
+
+const theme = {
+ ...DarkTheme,
+ colors: {
+ ...DarkTheme.colors,
+ background: palette.background,
+ card: palette.background,
+ border: palette.border,
+ primary: palette.text,
+ },
+};
+
+function PersistentMiniPlayer() {
+ const segments = useSegments();
+ const insets = useSafeAreaInsets();
+ const session = useSession();
+ const route = String(segments[segments.length - 1] ?? "");
+ if (
+ route === "player" ||
+ (session.phase !== "ready" && session.phase !== "offline")
+ )
+ return null;
+ const inTabs = String(segments[0]) === "(tabs)";
+ return (
+
+ );
+}
+
+export default function RootLayout() {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
+ }),
+ );
+ useEffect(() => {
+ void SplashScreen.hideAsync();
+ }, []);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/album/[id].tsx b/apps/mobile/src/app/album/[id].tsx
new file mode 100644
index 00000000..5bfa6aba
--- /dev/null
+++ b/apps/mobile/src/app/album/[id].tsx
@@ -0,0 +1,188 @@
+import { getAlbumInfo, type LibraryAlbum } from "@parson/music-sdk";
+import { useQuery } from "@tanstack/react-query";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { ArrowLeft, MoreHorizontal, Play } from "lucide-react-native";
+import { useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { Artwork } from "@/components/artwork";
+import { AlbumActions } from "@/components/album-actions";
+import { PauseGlyph } from "@/components/pause-glyph";
+import { Screen, SongRow } from "@/components/music-ui";
+import { palette } from "@/constants/colors";
+import { usePlayer } from "@/providers/player-provider";
+import { formatCollectionDuration } from "@/lib/format";
+
+export default function AlbumScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const player = usePlayer();
+ const album = useQuery({
+ queryKey: ["album", id],
+ queryFn: () => getAlbumInfo(id, false) as Promise,
+ enabled: !!id,
+ });
+ const [actionsOpen, setActionsOpen] = useState(false);
+ if (album.isPending)
+ return (
+
+
+
+ );
+ if (!album.data)
+ return (
+
+ Could not load album.
+
+ );
+ const data = album.data;
+ const artist =
+ data.artist_object?.name ??
+ data.contributing_artists?.[0] ??
+ "Unknown artist";
+ const totalDuration = formatCollectionDuration(
+ data.songs.reduce((sum, song) => sum + (song.duration || 0), 0),
+ );
+ const albumPlaying =
+ player.isPlaying && player.current?.album_object?.id === data.id;
+ return (
+
+
+
+
+
+
+ setActionsOpen(true)}>
+
+
+
+
+
+
+ {data.name}
+
+ data.artist_object?.id &&
+ router.push(`/artist/${data.artist_object.id}`)
+ }
+ >
+ {artist}
+
+
+ {data.songs.length} songs, {totalDuration}
+
+
+
+ albumPlaying
+ ? player.toggle()
+ : data.songs[0] &&
+ player.playSong(data.songs[0], data.songs)
+ }
+ >
+ {albumPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+ {data.songs.map((song, index) => (
+
+ ))}
+ {data.description ? (
+ {data.description}
+ ) : null}
+
+ setActionsOpen(false)}
+ albumId={data.id}
+ artistId={data.artist_object?.id}
+ name={data.name}
+ loaded={data}
+ showAlbum={false}
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ nav: {
+ height: 50,
+ paddingHorizontal: 20,
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ zIndex: 2,
+ },
+ hero: { paddingHorizontal: 20, alignItems: "flex-start" },
+ cover: {
+ alignSelf: "center",
+ marginVertical: 12,
+ shadowColor: "#000",
+ shadowOpacity: 0.7,
+ shadowRadius: 18,
+ elevation: 12,
+ },
+ title: {
+ color: "white",
+ fontSize: 30,
+ lineHeight: 34,
+ fontWeight: "900",
+ letterSpacing: -0.8,
+ marginTop: 14,
+ },
+ artistRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 9,
+ },
+ artist: { color: "white", fontWeight: "800", fontSize: 15 },
+ meta: { color: palette.secondary, fontSize: 13, marginTop: 5 },
+ actions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 17,
+ marginVertical: 22,
+ },
+ circle: {
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: "white",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ description: {
+ color: palette.secondary,
+ fontSize: 14,
+ lineHeight: 21,
+ margin: 20,
+ },
+ error: { color: palette.secondary, margin: 30 },
+});
diff --git a/apps/mobile/src/app/artist/[id].tsx b/apps/mobile/src/app/artist/[id].tsx
new file mode 100644
index 00000000..deb8d265
--- /dev/null
+++ b/apps/mobile/src/app/artist/[id].tsx
@@ -0,0 +1,146 @@
+import { getArtistInfo } from "@parson/music-sdk";
+import { useQuery } from "@tanstack/react-query";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { ArrowLeft } from "lucide-react-native";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { Artwork } from "@/components/artwork";
+import { Screen, SectionTitle } from "@/components/music-ui";
+import { AlbumActions } from "@/components/album-actions";
+import { useMemo, useState } from "react";
+import type { Album } from "@parson/music-sdk";
+import { palette } from "@/constants/colors";
+
+export default function ArtistScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const [selected, setSelected] = useState(null);
+ const artist = useQuery({
+ queryKey: ["artist", id],
+ queryFn: () => getArtistInfo(id),
+ enabled: !!id,
+ });
+ const sections = useMemo(() => {
+ const data = artist.data;
+ if (!data) return [];
+ const sort = (albums: Album[]) =>
+ [...albums].sort((a, b) => {
+ const left = Date.parse(a.first_release_date || "");
+ const right = Date.parse(b.first_release_date || "");
+ return (
+ (Number.isFinite(right) ? right : -Infinity) -
+ (Number.isFinite(left) ? left : -Infinity)
+ );
+ });
+ return data.discography?.length
+ ? data.discography.map((section) => ({
+ ...section,
+ albums: sort(section.albums),
+ }))
+ : [{ key: "albums", title: "Albums", albums: sort(data.albums) }];
+ }, [artist.data]);
+ if (artist.isPending)
+ return (
+
+
+
+ );
+ if (!artist.data) return ;
+ const data = artist.data;
+ return (
+
+
+
+
+
+
+
+
+
+ {data.name}
+ {data.description ? (
+
+ {data.description}
+
+ ) : null}
+
+ {sections.map((section) => (
+
+ {section.title}
+
+ {section.albums.map((album) => (
+ {
+ if (!selected) router.push(`/album/${album.id}`);
+ }}
+ onLongPress={() => setSelected(album)}
+ >
+
+
+ {album.name}
+
+
+ {album.primary_type || "Album"}
+
+
+ ))}
+
+
+ ))}
+
+ {selected ? (
+ setSelected(null)}
+ />
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ nav: { height: 48, paddingHorizontal: 20, justifyContent: "center" },
+ hero: { alignItems: "center", paddingHorizontal: 20, paddingBottom: 28 },
+ title: {
+ color: "white",
+ fontSize: 34,
+ fontWeight: "900",
+ letterSpacing: -1,
+ textAlign: "center",
+ },
+ description: {
+ color: palette.secondary,
+ textAlign: "center",
+ lineHeight: 20,
+ marginTop: 12,
+ },
+ grid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ paddingHorizontal: 20,
+ gap: 16,
+ },
+ card: { width: 156 },
+ albumName: {
+ color: "white",
+ fontWeight: "700",
+ marginTop: 8,
+ lineHeight: 18,
+ },
+ year: { color: palette.secondary, fontSize: 12, marginTop: 3 },
+});
diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx
new file mode 100644
index 00000000..043d398a
--- /dev/null
+++ b/apps/mobile/src/app/index.tsx
@@ -0,0 +1,268 @@
+import { Redirect } from "expo-router";
+import { Image } from "expo-image";
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { useState } from "react";
+
+import { palette } from "@/constants/colors";
+import { useLibraryDiscovery } from "@/hooks/use-library-discovery";
+import { useSession } from "@/providers/session-provider";
+
+export default function EntryScreen() {
+ const session = useSession();
+ useLibraryDiscovery();
+ const [server, setServer] = useState(session.origin ?? "");
+ const [serverEdited, setServerEdited] = useState(false);
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [setupCode, setSetupCode] = useState("");
+ const [libraryPath, setLibraryPath] = useState("");
+ const serverValue = serverEdited ? server : (session.origin ?? server);
+ if (session.phase === "ready") return ;
+ if (session.phase === "offline") return ;
+
+ const busy =
+ session.phase === "loading" ||
+ session.phase === "connecting" ||
+ session.phase === "indexing";
+ const heading =
+ session.phase === "login"
+ ? "Welcome back"
+ : session.phase === "indexing"
+ ? "Preparing your library"
+ : session.phase === "setup"
+ ? session.setupStatus?.account_setup_required
+ ? "Create your account"
+ : "Choose your music folder"
+ : "Finding your library";
+ const detail =
+ session.phase === "indexing"
+ ? "Parson is indexing your music. This screen will update automatically."
+ : session.phase === "setup"
+ ? (session.setupStatus?.message ??
+ "Finish setting up this Parson library.")
+ : session.phase === "login"
+ ? (session.libraryName ?? "Sign in to your Parson library")
+ : "";
+
+ return (
+
+
+
+ {heading}
+ {detail ? {detail} : null}
+ {busy && (
+
+ )}
+ {session.phase === "setup" ? (
+
+ {session.setupStatus?.account_setup_required ? (
+ <>
+
+
+ {session.setupStatus.setup_code_required ? (
+
+ ) : null}
+
+ void session.setupAccount(username, password, setupCode)
+ }
+ >
+ Create account
+
+ >
+ ) : (
+ <>
+
+
+ void session.setupLibrary(
+ libraryPath ||
+ session.setupStatus?.suggested_library_path ||
+ "",
+ )
+ }
+ >
+ Index library
+
+ >
+ )}
+
+ ) : session.phase === "login" ? (
+
+
+ void session.login(username, password)}
+ />
+ void session.login(username, password)}
+ >
+ Sign in
+
+ void session.changeServer()}>
+ Choose another library
+
+
+ ) : session.phase === "discovering" ||
+ session.phase === "connecting" ||
+ session.phase === "loading" ? (
+
+ {
+ setServerEdited(true);
+ setServer(value);
+ }}
+ onSubmitEditing={() => void session.connect(serverValue)}
+ />
+ void session.connect(serverValue)}
+ >
+
+ {session.phase === "connecting" ? "Connecting…" : "Connect"}
+
+
+
+ ) : null}
+ {session.error &&
+ (session.phase === "login" ||
+ session.phase === "setup" ||
+ session.phase === "discovering") ? (
+
+ Something went wrong. Please try again.
+
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ page: {
+ flex: 1,
+ backgroundColor: "black",
+ justifyContent: "center",
+ paddingHorizontal: 28,
+ },
+ content: {
+ alignItems: "center",
+ width: "100%",
+ maxWidth: 440,
+ alignSelf: "center",
+ },
+ logo: { width: 72, height: 72, borderRadius: 18, marginBottom: 26 },
+ heading: {
+ color: "white",
+ fontSize: 28,
+ fontWeight: "800",
+ letterSpacing: -0.6,
+ textAlign: "center",
+ },
+ detail: {
+ color: palette.secondary,
+ textAlign: "center",
+ fontSize: 15,
+ lineHeight: 22,
+ marginTop: 10,
+ maxWidth: 330,
+ },
+ spinner: { marginTop: 25 },
+ form: { width: "100%", gap: 12, marginTop: 28 },
+ input: {
+ height: 52,
+ borderRadius: 12,
+ backgroundColor: palette.elevatedStrong,
+ borderWidth: 1,
+ borderColor: palette.border,
+ color: "white",
+ paddingHorizontal: 16,
+ fontSize: 16,
+ },
+ primary: {
+ height: 52,
+ backgroundColor: "white",
+ borderRadius: 26,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ primaryText: { color: "black", fontSize: 16, fontWeight: "800" },
+ disabledPrimary: { opacity: 0.62 },
+ link: { color: "white", textAlign: "center", fontWeight: "700", padding: 12 },
+ error: { color: "#fb7185", textAlign: "center", marginTop: 16 },
+});
diff --git a/apps/mobile/src/app/player.tsx b/apps/mobile/src/app/player.tsx
new file mode 100644
index 00000000..f25d8cb7
--- /dev/null
+++ b/apps/mobile/src/app/player.tsx
@@ -0,0 +1,778 @@
+import Slider from "@react-native-community/slider";
+/* eslint-disable react-hooks/immutability -- Reanimated SharedValues are mutated only inside gesture worklets. */
+import {
+ addFavoriteSong,
+ findLyrics,
+ isFavoriteSong,
+ removeFavoriteSong,
+} from "@parson/music-sdk";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { LinearGradient } from "expo-linear-gradient";
+import { useRouter } from "expo-router";
+import {
+ ChevronDown,
+ ChevronLeft,
+ ListMusic,
+ Disc3,
+ Download,
+ ListEnd,
+ ListPlus,
+ MoreHorizontal,
+ Play,
+ Repeat,
+ Repeat1,
+ SkipBack,
+ SkipForward,
+ Heart,
+ Gauge,
+ AudioLines,
+ TimerReset,
+ X,
+ UserRound,
+} from "lucide-react-native";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ useWindowDimensions,
+ View,
+} from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from "react-native-reanimated";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { Artwork } from "@/components/artwork";
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import { SongRow } from "@/components/music-ui";
+import { palette } from "@/constants/colors";
+import {
+ playerAudioPresets,
+ usePlayer,
+ usePlayerPosition,
+ type AudioPreset,
+} from "@/providers/player-provider";
+import {
+ downloadSong,
+ isSongDownloaded,
+ removeDownload,
+ useDownloadsRevision,
+} from "@/lib/downloads";
+import { PlaylistPicker } from "@/components/playlist-picker";
+import { PauseGlyph } from "@/components/pause-glyph";
+
+type Panel = "player" | "queue" | "lyrics";
+const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
+const presetIcons = {
+ original: AudioLines,
+ slow: TimerReset,
+ "deep-slow": TimerReset,
+ faster: Gauge,
+} satisfies Record;
+
+const time = (seconds: number) =>
+ `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60)
+ .toString()
+ .padStart(2, "0")}`;
+
+type Player = ReturnType;
+type PlayerSong = NonNullable;
+type Router = ReturnType;
+type SyncedLine = { at: number; text: string };
+
+function PlayerHeader({
+ panel,
+ onBack,
+ onActions,
+}: {
+ panel: Panel;
+ onBack: () => void;
+ onActions: () => void;
+}) {
+ return (
+
+
+ {panel === "player" ? (
+
+ ) : (
+
+ )}
+
+
+ {panel === "player" ? "" : panel === "lyrics" ? "Lyrics" : "Queue"}
+
+ {panel === "player" ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function NowPlayingPanel({
+ player,
+ song,
+ router,
+ currentTime,
+ duration,
+ favorite,
+ onToggleFavorite,
+ onQueue,
+ onLyrics,
+ onSound,
+}: {
+ player: Player;
+ song: PlayerSong;
+ router: Router;
+ currentTime: number;
+ duration: number;
+ favorite: boolean;
+ onToggleFavorite: () => void;
+ onQueue: () => void;
+ onLyrics: () => void;
+ onSound: () => void;
+}) {
+ const [scrubPosition, setScrubPosition] = useState(null);
+ const settlingSeek = useRef<{ target: number; expiresAt: number } | null>(
+ null,
+ );
+ useEffect(() => {
+ const pending = settlingSeek.current;
+ if (!pending) return;
+ if (
+ Math.abs(currentTime - pending.target) < 0.65 ||
+ Date.now() >= pending.expiresAt
+ ) {
+ settlingSeek.current = null;
+ setScrubPosition(null);
+ return;
+ }
+ const timeout = setTimeout(
+ () => {
+ settlingSeek.current = null;
+ setScrubPosition(null);
+ },
+ Math.max(0, pending.expiresAt - Date.now()),
+ );
+ return () => clearTimeout(timeout);
+ }, [currentTime]);
+ const shownTime = scrubPosition ?? currentTime;
+ const presetLabel = playerAudioPresets.find(
+ (preset) => preset.id === player.audioPreset,
+ )?.label;
+ return (
+
+
+
+
+
+ song.album_object?.id &&
+ router.replace(`/album/${song.album_object.id}`)
+ }
+ >
+ {song.name}
+
+
+ song.artist_object?.id &&
+ router.replace(`/artist/${song.artist_object.id}`)
+ }
+ >
+ {song.artist}
+
+
+
+
+
+
+
+ {
+ setScrubPosition(target);
+ settlingSeek.current = {
+ target,
+ expiresAt: Date.now() + 1_200,
+ };
+ player.seek(target);
+ }}
+ minimumTrackTintColor="white"
+ maximumTrackTintColor="#66666d"
+ thumbTintColor="white"
+ style={styles.slider}
+ />
+
+
+ {time(shownTime)}
+
+ -{time(Math.max(0, duration - shownTime))}
+
+
+
+
+ {player.repeat === "one" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {player.isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Lyrics
+
+ {player.audioPresetsEnabled ? (
+
+
+ {player.audioPreset === "original" ? "Sound" : presetLabel}
+
+
+ ) : null}
+
+
+ );
+}
+
+function QueuePanel({ player }: { player: Player }) {
+ return (
+
+
+ {player.queue.map((item, index) => (
+
+ ))}
+
+
+ );
+}
+
+function LyricsPanel({
+ pending,
+ synced,
+ plainLyrics,
+ instrumental,
+ currentTime,
+ onSeek,
+}: {
+ pending: boolean;
+ synced: SyncedLine[];
+ plainLyrics?: string | null;
+ instrumental?: boolean;
+ currentTime: number;
+ onSeek: (seconds: number) => void;
+}) {
+ const scrollRef = useRef(null);
+ const lineLayouts = useRef(new Map());
+ const lastCenteredLine = useRef(-1);
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [contentHeight, setContentHeight] = useState(0);
+ const activeLine = synced.reduce(
+ (found, line, index) => (line.at <= currentTime ? index : found),
+ -1,
+ );
+ useEffect(() => {
+ lineLayouts.current.clear();
+ lastCenteredLine.current = -1;
+ }, [synced]);
+ useEffect(() => {
+ const layout = lineLayouts.current.get(activeLine);
+ if (!layout || !viewportHeight) return;
+ const frame = requestAnimationFrame(() => {
+ scrollRef.current?.scrollTo({
+ y: Math.max(0, layout.y - (viewportHeight - layout.height) / 2),
+ animated: lastCenteredLine.current >= 0,
+ });
+ lastCenteredLine.current = activeLine;
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [activeLine, contentHeight, viewportHeight]);
+ return (
+
+
+ setViewportHeight(nativeEvent.layout.height)
+ }
+ onContentSizeChange={(_width, height) => setContentHeight(height)}
+ contentContainerStyle={[
+ styles.lyricsContent,
+ synced.length
+ ? { paddingBottom: Math.max(24, viewportHeight / 2) }
+ : null,
+ ]}
+ showsVerticalScrollIndicator={false}
+ >
+ {pending ? (
+ •••
+ ) : synced.length ? (
+ synced.map((line, index) => {
+ const captureLayout = ({
+ nativeEvent,
+ }: {
+ nativeEvent: { layout: { height: number; y: number } };
+ }) => lineLayouts.current.set(index, nativeEvent.layout);
+ return line.text.trim() ? (
+ onSeek(line.at)}
+ onLayout={captureLayout}
+ style={[
+ styles.lyricLine,
+ index === activeLine
+ ? styles.activeLyric
+ : index < activeLine
+ ? styles.pastLyric
+ : styles.futureLyric,
+ ]}
+ >
+ {line.text}
+
+ ) : (
+
+ );
+ })
+ ) : plainLyrics ? (
+ {plainLyrics}
+ ) : (
+
+
+ {instrumental
+ ? "Instrumental"
+ : "No lyrics are available for this track"}
+
+
+ )}
+
+
+ );
+}
+
+function PlayerDrawers({
+ player,
+ song,
+ router,
+ actionsOpen,
+ onActionsClose,
+ soundOpen,
+ onSoundClose,
+}: {
+ player: Player;
+ song: PlayerSong;
+ router: Router;
+ actionsOpen: boolean;
+ onActionsClose: () => void;
+ soundOpen: boolean;
+ onSoundClose: () => void;
+}) {
+ const [playlistPicker, setPlaylistPicker] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+ return (
+ <>
+
+ {
+ player.addNext(song);
+ onActionsClose();
+ }}
+ />
+ {
+ player.addToQueue([song]);
+ onActionsClose();
+ }}
+ />
+ {song.album_object?.id ? (
+ {
+ onActionsClose();
+ router.replace(`/album/${song.album_object.id}`);
+ }}
+ />
+ ) : null}
+ {song.artist_object?.id ? (
+ {
+ onActionsClose();
+ router.replace(`/artist/${song.artist_object.id}`);
+ }}
+ />
+ ) : null}
+ {
+ onActionsClose();
+ setPlaylistPicker(true);
+ }}
+ />
+ {
+ if (downloading) return;
+ if (isSongDownloaded(song.id)) {
+ void removeDownload(song.id).then(onActionsClose);
+ return;
+ }
+ setDownloading(true);
+ void downloadSong(song)
+ .then(onActionsClose)
+ .finally(() => setDownloading(false));
+ }}
+ />
+
+ {player.audioPresetsEnabled ? (
+
+ {playerAudioPresets.map((preset) => (
+ {
+ player.setAudioPreset(preset.id);
+ onSoundClose();
+ }}
+ />
+ ))}
+
+ ) : null}
+ setPlaylistPicker(false)}
+ songId={song.id}
+ />
+ >
+ );
+}
+
+export default function PlayerScreen() {
+ const router = useRouter();
+ const client = useQueryClient();
+ const player = usePlayer();
+ const { currentTime, duration } = usePlayerPosition();
+ const [panel, setPanel] = useState("player");
+ const [actions, setActions] = useState(false);
+ const [soundOptions, setSoundOptions] = useState(false);
+ const { height: screenHeight } = useWindowDimensions();
+ const translateY = useSharedValue(0);
+ const dismissPlayer = useCallback(() => router.back(), [router]);
+ const playerDragStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+ const dragDownGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .enabled(panel === "player")
+ .activeOffsetY(14)
+ .failOffsetX([-28, 28])
+ .onUpdate(({ translationY }) => {
+ translateY.value = Math.max(0, translationY);
+ })
+ .onEnd(({ translationY, velocityY }) => {
+ if (translationY > 72 || velocityY > 700) {
+ translateY.value = withTiming(
+ screenHeight,
+ { duration: 170 },
+ () => {
+ runOnJS(dismissPlayer)();
+ },
+ );
+ } else {
+ translateY.value = withSpring(0, { damping: 22, stiffness: 260 });
+ }
+ }),
+ [dismissPlayer, panel, screenHeight, translateY],
+ );
+ useDownloadsRevision();
+ const lyrics = useQuery({
+ queryKey: ["lyrics", player.current?.id],
+ queryFn: () => findLyrics(player.current!.id),
+ enabled: panel === "lyrics" && !!player.current,
+ staleTime: 30 * 60_000,
+ retry: false,
+ });
+ const favorite = useQuery({
+ queryKey: ["favorite-membership", player.current?.id],
+ queryFn: () => isFavoriteSong(player.current!.id),
+ enabled: !!player.current,
+ staleTime: 30_000,
+ });
+ const synced = useMemo(() => {
+ const source = lyrics.data?.syncedLyrics;
+ if (!source) return [];
+ return source.split("\n").flatMap((line) => {
+ const match = line.match(/^\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)$/);
+ return match
+ ? [
+ {
+ at: Number(match[1]) * 60 + Number(match[2]),
+ text: match[3] ?? "",
+ },
+ ]
+ : [];
+ });
+ }, [lyrics.data?.syncedLyrics]);
+ if (!player.current)
+ return (
+
+
+
+
+
+
+
+ );
+ const song = player.current;
+ const toggleFavorite = async () => {
+ const next = !favorite.data;
+ client.setQueryData(["favorite-membership", song.id], next);
+ try {
+ if (next) await addFavoriteSong(song.id);
+ else await removeFavoriteSong(song.id);
+ await client.invalidateQueries({ queryKey: ["favorite-song-details"] });
+ } catch {
+ client.setQueryData(["favorite-membership", song.id], !next);
+ }
+ };
+ return (
+
+
+
+
+ panel === "player" ? router.back() : setPanel("player")
+ }
+ onActions={() => setActions(true)}
+ />
+ {panel === "player" ? (
+ void toggleFavorite()}
+ onQueue={() => setPanel("queue")}
+ onLyrics={() => setPanel("lyrics")}
+ onSound={() => setSoundOptions(true)}
+ />
+ ) : panel === "queue" ? (
+
+ ) : (
+
+ )}
+ setActions(false)}
+ soundOpen={soundOptions}
+ onSoundClose={() => setSoundOptions(false)}
+ />
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ page: { flex: 1, backgroundColor: "black" },
+ down: { padding: 20 },
+ header: {
+ height: 56,
+ paddingHorizontal: 20,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ panelHeader: { color: "white", fontSize: 15, fontWeight: "800" },
+ player: { flex: 1, paddingHorizontal: 26, justifyContent: "space-evenly" },
+ art: { alignSelf: "center", maxWidth: "100%", aspectRatio: 1 },
+ songInfo: {
+ marginTop: 14,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 14,
+ },
+ title: {
+ color: "white",
+ fontSize: 24,
+ fontWeight: "900",
+ letterSpacing: -0.5,
+ },
+ artist: { color: palette.secondary, fontSize: 16, marginTop: 5 },
+ sliderHitbox: {
+ width: "100%",
+ height: 52,
+ marginTop: 2,
+ justifyContent: "center",
+ },
+ slider: { width: "100%", height: 52 },
+ times: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ marginTop: -12,
+ },
+ time: {
+ color: palette.secondary,
+ fontSize: 11,
+ fontVariant: ["tabular-nums"],
+ },
+ controls: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginVertical: 10,
+ },
+ playButton: {
+ width: 68,
+ height: 68,
+ borderRadius: 34,
+ backgroundColor: "white",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ panelButtons: { flexDirection: "row", gap: 8 },
+ panelButton: {
+ flex: 1,
+ height: 46,
+ borderRadius: 12,
+ backgroundColor: "rgba(255,255,255,.1)",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ panelText: { color: "white", fontWeight: "800" },
+ panel: { flex: 1, paddingTop: 18 },
+ panelTitle: {
+ color: "white",
+ fontSize: 30,
+ fontWeight: "900",
+ paddingHorizontal: 20,
+ },
+ lyrics: {
+ color: "white",
+ fontSize: 24,
+ lineHeight: 35,
+ fontWeight: "700",
+ paddingHorizontal: 24,
+ textAlign: "center",
+ },
+ lyricLine: {
+ color: "white",
+ fontSize: 25,
+ lineHeight: 34,
+ fontWeight: "800",
+ paddingHorizontal: 24,
+ paddingVertical: 7,
+ textAlign: "center",
+ },
+ lyricsContent: {
+ flexGrow: 1,
+ paddingVertical: 24,
+ },
+ activeLyric: { color: "white", transform: [{ scale: 1.03 }] },
+ pastLyric: { color: "rgba(255,255,255,.25)" },
+ futureLyric: { color: "rgba(255,255,255,.45)" },
+ lyricSpacer: { height: 20 },
+ loadingLyrics: {
+ color: palette.secondary,
+ fontSize: 24,
+ textAlign: "center",
+ marginTop: 40,
+ letterSpacing: 5,
+ },
+ emptyLyrics: {
+ padding: 24,
+ margin: 20,
+ backgroundColor: "rgba(255,255,255,.08)",
+ borderRadius: 16,
+ },
+ emptyTitle: { color: "white", fontWeight: "900", fontSize: 18 },
+ emptyDetail: { color: palette.secondary, lineHeight: 20, marginTop: 8 },
+});
diff --git a/apps/mobile/src/app/playlist/[id].tsx b/apps/mobile/src/app/playlist/[id].tsx
new file mode 100644
index 00000000..0bfa4b03
--- /dev/null
+++ b/apps/mobile/src/app/playlist/[id].tsx
@@ -0,0 +1,293 @@
+import {
+ deletePlaylist,
+ getPlaylist,
+ removeSongFromPlaylist,
+ updatePlaylist,
+} from "@parson/music-sdk";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import {
+ ArrowLeft,
+ MoreHorizontal,
+ Pencil,
+ Play,
+ Trash2,
+} from "lucide-react-native";
+import { useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import { Artwork } from "@/components/artwork";
+import { Screen, SongRow } from "@/components/music-ui";
+import { palette } from "@/constants/colors";
+import { usePlayer } from "@/providers/player-provider";
+import { formatCollectionDuration } from "@/lib/format";
+
+export default function PlaylistScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const player = usePlayer();
+ const client = useQueryClient();
+ const [menu, setMenu] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [savingEdit, setSavingEdit] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const query = useQuery({
+ queryKey: ["playlist", id],
+ queryFn: () => getPlaylist(Number(id)),
+ enabled: !!id,
+ });
+ if (query.isPending)
+ return (
+
+
+
+ );
+ if (!query.data) return ;
+ const data = query.data;
+ const removeSong = async (songId: string) => {
+ const previous = data;
+ const removed = data.songs.find((song) => song.id === songId);
+ client.setQueryData(["playlist", id], {
+ ...data,
+ songs: data.songs.filter((song) => song.id !== songId),
+ song_count: Math.max(0, data.song_count - 1),
+ total_duration: Math.max(
+ 0,
+ data.total_duration - (removed?.duration ?? 0),
+ ),
+ });
+ try {
+ await removeSongFromPlaylist(data.id, songId);
+ void client.invalidateQueries({ queryKey: ["playlist", id] });
+ void client.invalidateQueries({ queryKey: ["playlists"] });
+ } catch {
+ client.setQueryData(["playlist", id], previous);
+ }
+ };
+ const saveEdit = async () => {
+ if (!editName.trim() || savingEdit) return;
+ setSavingEdit(true);
+ try {
+ await updatePlaylist(data.id, {
+ name: editName.trim(),
+ description: editDescription.trim(),
+ });
+ client.setQueryData(["playlist", id], {
+ ...data,
+ name: editName.trim(),
+ description: editDescription.trim(),
+ });
+ setEditing(false);
+ void client.invalidateQueries({ queryKey: ["playlist", id] });
+ void client.invalidateQueries({ queryKey: ["playlists"] });
+ } finally {
+ setSavingEdit(false);
+ }
+ };
+ const duration = formatCollectionDuration(data.total_duration);
+ return (
+
+
+
+
+
+
+ setMenu(true)}>
+
+
+
+
+
+
+ PLAYLIST
+ {data.name}
+ {data.description ? (
+ {data.description}
+ ) : null}
+
+ {data.song_count} songs, {duration}
+
+
+ data.songs[0] && player.playSong(data.songs[0], data.songs)
+ }
+ >
+
+
+
+ {!data.songs.length ? (
+
+ This playlist is empty
+
+ Open any song’s actions and choose Add to playlist.
+
+
+ ) : null}
+ {data.songs.map((song, index) => (
+ void removeSong(song.id)}
+ />
+ ))}
+
+ setMenu(false)}
+ title={data.name}
+ >
+ {
+ setMenu(false);
+ if (data.songs[0]) player.playSong(data.songs[0], data.songs);
+ }}
+ />
+ {
+ setMenu(false);
+ setEditName(data.name);
+ setEditDescription(data.description ?? "");
+ setEditing(true);
+ }}
+ />
+ {
+ setMenu(false);
+ Alert.alert("Delete playlist?", data.name, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () =>
+ void deletePlaylist(data.id).then(() => router.back()),
+ },
+ ]);
+ }}
+ />
+
+ setEditing(false)}
+ title="Edit playlist"
+ >
+
+
+
+ void saveEdit()}
+ >
+
+ {savingEdit ? "Saving changes…" : "Save changes"}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ nav: {
+ height: 50,
+ paddingHorizontal: 20,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ hero: { padding: 20, alignItems: "flex-start" },
+ type: {
+ color: palette.secondary,
+ fontSize: 11,
+ fontWeight: "800",
+ letterSpacing: 1.2,
+ marginTop: 22,
+ },
+ title: { color: "white", fontSize: 32, fontWeight: "900", marginTop: 5 },
+ description: { color: palette.secondary, marginTop: 8, lineHeight: 20 },
+ meta: { color: palette.secondary, marginTop: 8 },
+ play: {
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: "white",
+ alignItems: "center",
+ justifyContent: "center",
+ marginTop: 20,
+ },
+ editForm: { padding: 10, gap: 10 },
+ input: {
+ minHeight: 50,
+ borderRadius: 10,
+ backgroundColor: "#29292e",
+ color: "white",
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ descriptionInput: { minHeight: 90, textAlignVertical: "top" },
+ save: {
+ height: 50,
+ borderRadius: 25,
+ backgroundColor: "white",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ saveText: { color: "black", fontWeight: "800" },
+ empty: {
+ margin: 20,
+ padding: 28,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: palette.border,
+ alignItems: "center",
+ },
+ emptyTitle: { color: "white", fontSize: 16, fontWeight: "800" },
+ emptyText: {
+ color: palette.secondary,
+ textAlign: "center",
+ lineHeight: 20,
+ marginTop: 7,
+ },
+});
diff --git a/apps/mobile/src/components/action-drawer.tsx b/apps/mobile/src/components/action-drawer.tsx
new file mode 100644
index 00000000..e8413813
--- /dev/null
+++ b/apps/mobile/src/components/action-drawer.tsx
@@ -0,0 +1,267 @@
+/* eslint-disable react-hooks/immutability, react-hooks/refs -- Reanimated SharedValues are mutated only inside UI-thread gesture worklets. */
+import type { LucideIcon } from "lucide-react-native";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PropsWithChildren,
+} from "react";
+import {
+ BackHandler,
+ Pressable,
+ StyleSheet,
+ Text,
+ useWindowDimensions,
+ View,
+} from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ Extrapolation,
+ interpolate,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { palette } from "@/constants/colors";
+
+type DrawerRequest = {
+ children: React.ReactNode;
+ id: symbol;
+ onClose: () => void;
+ title?: string;
+};
+
+type DrawerPortal = {
+ dismiss: (id: symbol) => void;
+ present: (request: DrawerRequest) => void;
+};
+
+const DrawerPortalContext = createContext(null);
+
+export function ActionDrawerProvider({ children }: PropsWithChildren) {
+ const [request, setRequest] = useState(null);
+ const present = useCallback((next: DrawerRequest) => setRequest(next), []);
+ const dismiss = useCallback(
+ (id: symbol) =>
+ setRequest((current) => (current?.id === id ? null : current)),
+ [],
+ );
+ const portal = useMemo(() => ({ dismiss, present }), [dismiss, present]);
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export function ActionDrawer({
+ children,
+ onClose,
+ open,
+ title,
+}: {
+ children: React.ReactNode;
+ onClose: () => void;
+ open: boolean;
+ title?: string;
+}) {
+ const portal = useContext(DrawerPortalContext);
+ const id = useRef(Symbol("action-drawer"));
+ useLayoutEffect(() => {
+ if (!portal) return;
+ if (open) portal.present({ children, id: id.current, onClose, title });
+ else portal.dismiss(id.current);
+ }, [children, onClose, open, portal, title]);
+ useEffect(
+ () => () => {
+ portal?.dismiss(id.current);
+ },
+ [portal],
+ );
+ return null;
+}
+
+function ActionDrawerHost({ request }: { request: DrawerRequest | null }) {
+ const insets = useSafeAreaInsets();
+ const { height: screenHeight } = useWindowDimensions();
+ const offscreenDistance = Math.max(900, screenHeight);
+ const entranceDistance = 64;
+ const translateY = useSharedValue(offscreenDistance);
+ const requestRef = useRef(request);
+ const requestId = request?.id;
+ const closeDrawer = useCallback(() => {
+ const active = requestRef.current;
+ if (active && active.id === requestId) active.onClose();
+ }, [requestId]);
+
+ useEffect(() => {
+ requestRef.current = request;
+ }, [request]);
+
+ useLayoutEffect(() => {
+ if (requestId) {
+ translateY.value = entranceDistance;
+ translateY.value = withTiming(0, { duration: 120 });
+ }
+ }, [requestId, translateY]);
+
+ const dismissDrawer = useCallback(() => {
+ translateY.value = withTiming(offscreenDistance, { duration: 140 }, () => {
+ runOnJS(closeDrawer)();
+ });
+ }, [closeDrawer, offscreenDistance, translateY]);
+
+ const drawerStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+ const backdropStyle = useAnimatedStyle(() => ({
+ opacity: interpolate(
+ translateY.value,
+ [0, Math.min(300, offscreenDistance)],
+ [0.54, 0],
+ Extrapolation.CLAMP,
+ ),
+ }));
+ useEffect(() => {
+ if (!request) return;
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => {
+ dismissDrawer();
+ return true;
+ },
+ );
+ return () => subscription.remove();
+ }, [dismissDrawer, request]);
+ const dragGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .activeOffsetY(8)
+ .failOffsetX([-30, 30])
+ .onUpdate(({ translationY }) => {
+ translateY.value = Math.max(0, translationY);
+ })
+ .onEnd(({ translationY, velocityY }) => {
+ if (translationY > 110 || velocityY > 850) {
+ translateY.value = withTiming(
+ offscreenDistance,
+ { duration: 140 },
+ () => {
+ runOnJS(closeDrawer)();
+ },
+ );
+ } else {
+ translateY.value = withSpring(0, {
+ damping: 22,
+ stiffness: 260,
+ });
+ }
+ }),
+ [closeDrawer, offscreenDistance, translateY],
+ );
+ if (!request) return null;
+ return (
+
+
+
+
+
+
+ {request.title ? (
+
+ {request.title}
+
+ ) : null}
+ {request.children}
+
+
+
+ );
+}
+
+export function DrawerAction({
+ icon: Icon,
+ label,
+ onPress,
+}: {
+ icon?: LucideIcon;
+ label: string;
+ onPress: () => void;
+}) {
+ return (
+ [styles.action, pressed && { opacity: 0.55 }]}
+ onPress={onPress}
+ >
+ {Icon ? : null}
+ {label}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ provider: { flex: 1 },
+ modal: {
+ ...StyleSheet.absoluteFill,
+ justifyContent: "flex-end",
+ zIndex: 1000,
+ elevation: 1000,
+ },
+ backdrop: {
+ position: "absolute",
+ inset: 0,
+ backgroundColor: "black",
+ },
+ drawer: {
+ backgroundColor: "#18181b",
+ borderTopLeftRadius: 22,
+ borderTopRightRadius: 22,
+ paddingTop: 10,
+ paddingHorizontal: 10,
+ },
+ handle: {
+ width: 38,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: "#66666d",
+ alignSelf: "center",
+ marginBottom: 10,
+ },
+ title: {
+ color: palette.secondary,
+ fontSize: 13,
+ fontWeight: "700",
+ paddingHorizontal: 14,
+ paddingVertical: 9,
+ },
+ action: {
+ minHeight: 53,
+ borderRadius: 12,
+ paddingHorizontal: 14,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 15,
+ },
+ label: { color: "white", fontSize: 16, fontWeight: "600" },
+});
diff --git a/apps/mobile/src/components/album-actions.tsx b/apps/mobile/src/components/album-actions.tsx
new file mode 100644
index 00000000..03042cfb
--- /dev/null
+++ b/apps/mobile/src/components/album-actions.tsx
@@ -0,0 +1,322 @@
+import {
+ editAlbumMetadata,
+ getAlbumInfo,
+ type LibraryAlbum,
+} from "@parson/music-sdk";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ Disc3,
+ Download,
+ ListEnd,
+ ListPlus,
+ Play,
+ Pencil,
+ UserRound,
+ X,
+} from "lucide-react-native";
+import { useRouter } from "expo-router";
+import { useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import {
+ downloadAlbum,
+ isSongDownloaded,
+ removeDownloads,
+ useDownloadsRevision,
+} from "@/lib/downloads";
+import { usePlayer } from "@/providers/player-provider";
+import { PlaylistPicker } from "@/components/playlist-picker";
+import { useSession } from "@/providers/session-provider";
+import { palette } from "@/constants/colors";
+
+export function AlbumActions({
+ albumId,
+ artistId,
+ name,
+ onClose,
+ open,
+ loaded,
+ showAlbum = true,
+ showArtist = true,
+}: {
+ albumId: string;
+ artistId?: string;
+ name?: string;
+ onClose: () => void;
+ open: boolean;
+ loaded?: LibraryAlbum;
+ showAlbum?: boolean;
+ showArtist?: boolean;
+}) {
+ const router = useRouter();
+ const player = usePlayer();
+ const session = useSession();
+ const client = useQueryClient();
+ const [pickingPlaylist, setPickingPlaylist] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [editorLoading, setEditorLoading] = useState(false);
+ const [editorSaving, setEditorSaving] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editArtist, setEditArtist] = useState("");
+ const [editDate, setEditDate] = useState("");
+ const [editType, setEditType] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [resolvedAlbum, setResolvedAlbum] = useState(null);
+ useDownloadsRevision();
+ useEffect(() => {
+ if (loaded || !open) return;
+ void (getAlbumInfo(albumId, false) as Promise)
+ .then(setResolvedAlbum)
+ .catch(() => {});
+ }, [albumId, loaded, open]);
+ const album = loaded ?? resolvedAlbum;
+ const albumDownloaded =
+ !!album?.songs.length &&
+ album.songs.every((song) => isSongDownloaded(song.id));
+ const load = () =>
+ loaded
+ ? Promise.resolve(loaded)
+ : album
+ ? Promise.resolve(album)
+ : (getAlbumInfo(albumId, false) as Promise).then(
+ (album) => {
+ setResolvedAlbum(album);
+ return album;
+ },
+ );
+ const closeThen = (run: () => void | Promise) => {
+ onClose();
+ void run();
+ };
+ const openEditor = async () => {
+ setEditing(true);
+ setEditorLoading(true);
+ try {
+ const album = await load();
+ setEditName(album.name);
+ setEditArtist(album.artist_object.name);
+ setEditDate(album.first_release_date ?? "");
+ setEditType(album.primary_type ?? "");
+ setEditDescription(album.description ?? "");
+ } finally {
+ setEditorLoading(false);
+ }
+ };
+ const saveEditor = async () => {
+ if (!editName.trim() || !editArtist.trim() || editorSaving) return;
+ setEditorSaving(true);
+ try {
+ await editAlbumMetadata(albumId, {
+ album: {
+ name: editName.trim(),
+ first_release_date: editDate.trim(),
+ primary_type: editType.trim(),
+ description: editDescription.trim(),
+ },
+ artist: { name: editArtist.trim() },
+ });
+ setEditing(false);
+ onClose();
+ void Promise.all([
+ client.invalidateQueries({ queryKey: ["album", albumId] }),
+ client.invalidateQueries({ queryKey: ["home"] }),
+ client.invalidateQueries({ queryKey: ["library"] }),
+ ]);
+ } finally {
+ setEditorSaving(false);
+ }
+ };
+ return (
+ <>
+
+
+ closeThen(async () => {
+ const album = await load();
+ if (album.songs[0]) player.playSong(album.songs[0], album.songs);
+ })
+ }
+ />
+
+ closeThen(async () => {
+ const album = await load();
+ player.addToQueue(album.songs);
+ })
+ }
+ />
+ {showAlbum ? (
+ closeThen(() => router.push(`/album/${albumId}`))}
+ />
+ ) : null}
+ {
+ if (downloading) return;
+ if (albumDownloaded && album) {
+ onClose();
+ void removeDownloads(album.songs.map((song) => song.id));
+ return;
+ }
+ setDownloading(true);
+ void (async () => {
+ const album = await load();
+ await downloadAlbum(album.name, album.songs);
+ setDownloading(false);
+ onClose();
+ })().catch(() => setDownloading(false));
+ }}
+ />
+ setPickingPlaylist(true)}
+ />
+ {artistId && showArtist ? (
+ closeThen(() => router.push(`/artist/${artistId}`))}
+ />
+ ) : null}
+ {session.claims?.role === "admin" ? (
+ void openEditor()}
+ />
+ ) : null}
+
+ {
+ setPickingPlaylist(false);
+ onClose();
+ }}
+ />
+ {
+ setEditing(false);
+ onClose();
+ }}
+ title="Edit album"
+ >
+ {editorLoading ? (
+
+
+ Loading album…
+
+ ) : (
+
+
+
+
+
+
+
+
+ void saveEditor()}
+ >
+
+ {editorSaving ? "Saving changes…" : "Save changes"}
+
+
+
+ )}
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ editorLoading: {
+ minHeight: 120,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 12,
+ },
+ editorLoadingText: { color: palette.secondary },
+ form: { padding: 10, gap: 9 },
+ row: { flexDirection: "row", gap: 9 },
+ flex: { flex: 1 },
+ input: {
+ minHeight: 48,
+ borderRadius: 10,
+ backgroundColor: "#29292e",
+ color: "white",
+ paddingHorizontal: 13,
+ paddingVertical: 11,
+ },
+ description: { minHeight: 88, textAlignVertical: "top" },
+ save: {
+ height: 50,
+ borderRadius: 25,
+ backgroundColor: "white",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ saveText: { color: "black", fontWeight: "800" },
+});
diff --git a/apps/mobile/src/components/artist-actions.tsx b/apps/mobile/src/components/artist-actions.tsx
new file mode 100644
index 00000000..da119267
--- /dev/null
+++ b/apps/mobile/src/components/artist-actions.tsx
@@ -0,0 +1,44 @@
+import { getArtistInfo } from "@parson/music-sdk";
+import { Play, UserRound } from "lucide-react-native";
+import { useRouter } from "expo-router";
+
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import { usePlayer } from "@/providers/player-provider";
+
+export function ArtistActions({
+ artistId,
+ name,
+ onClose,
+ open,
+}: {
+ artistId: string;
+ name?: string;
+ onClose: () => void;
+ open: boolean;
+}) {
+ const router = useRouter();
+ const player = usePlayer();
+ return (
+
+ {
+ onClose();
+ void getArtistInfo(artistId).then((artist) => {
+ const songs = artist.albums.flatMap((album) => album.songs);
+ if (songs[0]) player.playSong(songs[0], songs);
+ });
+ }}
+ />
+ {
+ onClose();
+ router.push(`/artist/${artistId}`);
+ }}
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/artwork.tsx b/apps/mobile/src/components/artwork.tsx
new file mode 100644
index 00000000..474ea6b6
--- /dev/null
+++ b/apps/mobile/src/components/artwork.tsx
@@ -0,0 +1,54 @@
+import { Image } from "expo-image";
+import { Music2 } from "lucide-react-native";
+import { useState } from "react";
+import { StyleSheet, View, type ViewStyle } from "react-native";
+
+import { palette } from "@/constants/colors";
+import { imageUrl } from "@/lib/runtime";
+
+export function Artwork({
+ path,
+ size,
+ rounded = 8,
+ style,
+}: {
+ path?: string | null;
+ size: number;
+ rounded?: number;
+ style?: ViewStyle;
+}) {
+ const uri = imageUrl(path);
+ const [failedUri, setFailedUri] = useState(null);
+ return (
+
+ {uri && failedUri !== uri ? (
+ setFailedUri(uri)}
+ transition={160}
+ />
+ ) : (
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ frame: {
+ backgroundColor: palette.elevatedStrong,
+ alignItems: "center",
+ justifyContent: "center",
+ overflow: "hidden",
+ },
+});
diff --git a/apps/mobile/src/components/mini-player.tsx b/apps/mobile/src/components/mini-player.tsx
new file mode 100644
index 00000000..4ac220cb
--- /dev/null
+++ b/apps/mobile/src/components/mini-player.tsx
@@ -0,0 +1,132 @@
+/* eslint-disable react-hooks/immutability -- Reanimated SharedValues are mutated only inside gesture worklets. */
+import { useRouter } from "expo-router";
+import { Play, SkipForward } from "lucide-react-native";
+import { useCallback, useMemo, useState } from "react";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ Extrapolation,
+ interpolate,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from "react-native-reanimated";
+
+import { Artwork } from "@/components/artwork";
+import { PauseGlyph } from "@/components/pause-glyph";
+import { layout, palette } from "@/constants/colors";
+import { usePlayer, usePlayerPosition } from "@/providers/player-provider";
+
+export function MiniPlayer({ bottom }: { bottom: number }) {
+ const router = useRouter();
+ const { current, isPlaying, toggle, next } = usePlayer();
+ const { currentTime, duration } = usePlayerPosition();
+ const [opening, setOpening] = useState(false);
+ const expansion = useSharedValue(0);
+ const openPlayerFromDrag = useCallback(() => {
+ setOpening(true);
+ router.push("/player");
+ }, [router]);
+ const openPlayerFromTap = useCallback(() => {
+ router.push("/player");
+ }, [router]);
+ const dragStyle = useAnimatedStyle(() => ({
+ borderRadius: interpolate(
+ expansion.value,
+ [0, 180],
+ [12, 0],
+ Extrapolation.CLAMP,
+ ),
+ height: layout.miniPlayer + expansion.value,
+ left: interpolate(expansion.value, [0, 180], [8, 0], Extrapolation.CLAMP),
+ right: interpolate(expansion.value, [0, 180], [8, 0], Extrapolation.CLAMP),
+ }));
+ const dragUpGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .activeOffsetY(-12)
+ .failOffsetX([-28, 28])
+ .onUpdate(({ translationY }) => {
+ expansion.value = Math.max(0, -translationY);
+ })
+ .onEnd(({ translationY, velocityY }) => {
+ if (translationY < -36 || velocityY < -600) {
+ runOnJS(openPlayerFromDrag)();
+ } else {
+ expansion.value = withSpring(0, {
+ damping: 22,
+ stiffness: 260,
+ });
+ }
+ }),
+ [expansion, openPlayerFromDrag],
+ );
+ if (!current || opening) return null;
+ const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
+ return (
+
+
+
+
+
+
+ {current.name}
+
+
+ {current.artist}
+
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ shell: {
+ position: "absolute",
+ left: 8,
+ right: 8,
+ height: layout.miniPlayer,
+ borderRadius: 12,
+ backgroundColor: "#202024",
+ flexDirection: "row",
+ alignItems: "center",
+ padding: 8,
+ zIndex: 20,
+ overflow: "hidden",
+ },
+ info: { flex: 1, flexDirection: "row", alignItems: "center", gap: 11 },
+ labels: { flex: 1 },
+ title: { color: palette.text, fontWeight: "700", fontSize: 14 },
+ artist: { color: palette.secondary, fontSize: 12, marginTop: 2 },
+ control: {
+ width: 44,
+ height: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ progress: {
+ position: "absolute",
+ left: 0,
+ bottom: 0,
+ height: 2,
+ backgroundColor: "white",
+ },
+});
diff --git a/apps/mobile/src/components/music-ui.tsx b/apps/mobile/src/components/music-ui.tsx
new file mode 100644
index 00000000..f55fae0e
--- /dev/null
+++ b/apps/mobile/src/components/music-ui.tsx
@@ -0,0 +1,299 @@
+import type { LibrarySong, ResponseAlbum } from "@parson/music-sdk";
+import { useRouter } from "expo-router";
+import {
+ Disc3,
+ Download,
+ ListEnd,
+ ListPlus,
+ MoreHorizontal,
+ Play,
+ X,
+ UserRound,
+} from "lucide-react-native";
+import { useState } from "react";
+import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+
+import { Artwork } from "@/components/artwork";
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import { palette } from "@/constants/colors";
+import { usePlayer } from "@/providers/player-provider";
+import {
+ downloadSong,
+ isSongDownloaded,
+ removeDownload,
+ useDownloadsRevision,
+} from "@/lib/downloads";
+import { AlbumActions } from "@/components/album-actions";
+import { PlaylistPicker } from "@/components/playlist-picker";
+
+export function Screen({ children }: { children?: React.ReactNode }) {
+ return {children} ;
+}
+
+export function PageTitle({
+ children,
+ subtitle,
+}: {
+ children: React.ReactNode;
+ subtitle?: string;
+}) {
+ return (
+
+ {children}
+ {subtitle ? {subtitle} : null}
+
+ );
+}
+
+export function SectionTitle({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+
+export function SongRow({
+ song,
+ queue,
+ index,
+ onRemove,
+ showAlbum = true,
+}: {
+ song: LibrarySong;
+ queue?: LibrarySong[];
+ index?: number;
+ onRemove?: () => void;
+ showAlbum?: boolean;
+}) {
+ const player = usePlayer();
+ const router = useRouter();
+ const [menu, setMenu] = useState(false);
+ const [playlistPicker, setPlaylistPicker] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+ useDownloadsRevision();
+ const active = player.current?.id === song.id;
+ return (
+ [styles.songRow, pressed && styles.pressed]}
+ onPress={() => {
+ if (menu) return;
+ if (active) player.toggle();
+ else player.playSong(song, queue);
+ }}
+ onLongPress={() => setMenu(true)}
+ delayLongPress={220}
+ >
+ {index !== undefined ? (
+ {index + 1}
+ ) : (
+
+ )}
+
+
+ {song.name}
+
+
+ {song.artist}
+ {showAlbum && song.album_object?.name
+ ? ` • ${song.album_object.name}`
+ : ""}
+
+
+ {
+ event.stopPropagation();
+ setMenu(true);
+ }}
+ style={styles.rowMenuButton}
+ >
+
+
+ setMenu(false)}
+ title={`${song.name} • ${song.artist}`}
+ >
+ {
+ setMenu(false);
+ player.playSong(song, queue);
+ }}
+ />
+ {
+ setMenu(false);
+ player.addNext(song);
+ }}
+ />
+ {
+ setMenu(false);
+ player.addToQueue([song]);
+ }}
+ />
+ {song.album_object?.id ? (
+ {
+ setMenu(false);
+ router.push(`/album/${song.album_object.id}`);
+ }}
+ />
+ ) : null}
+ {song.artist_object?.id ? (
+ {
+ setMenu(false);
+ router.push(`/artist/${song.artist_object.id}`);
+ }}
+ />
+ ) : null}
+ {
+ setMenu(false);
+ setPlaylistPicker(true);
+ }}
+ />
+ {
+ if (downloading) return;
+ if (isSongDownloaded(song.id)) {
+ void removeDownload(song.id).then(() => setMenu(false));
+ return;
+ }
+ setDownloading(true);
+ void downloadSong(song)
+ .then(() => setMenu(false))
+ .finally(() => setDownloading(false));
+ }}
+ />
+ {onRemove ? (
+ {
+ setMenu(false);
+ onRemove();
+ }}
+ />
+ ) : null}
+
+ setPlaylistPicker(false)}
+ songId={song.id}
+ />
+
+ );
+}
+
+export function AlbumRail({ albums }: { albums: ResponseAlbum[] }) {
+ const router = useRouter();
+ const [selected, setSelected] = useState(null);
+ return (
+ <>
+
+ {albums.map((album) => (
+ {
+ if (!selected) router.push(`/album/${album.id}`);
+ }}
+ onLongPress={() => setSelected(album)}
+ >
+
+
+ {album.name}
+
+
+ {album.artist_object?.name ??
+ album.contributing_artists?.[0] ??
+ "Album"}
+
+
+ ))}
+
+ {selected ? (
+ setSelected(null)}
+ />
+ ) : null}
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: palette.background },
+ titleBlock: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
+ pageTitle: {
+ color: "white",
+ fontSize: 30,
+ lineHeight: 36,
+ fontWeight: "900",
+ letterSpacing: -1,
+ },
+ subtitle: { color: palette.secondary, marginTop: 4, fontSize: 14 },
+ sectionTitle: {
+ color: "white",
+ fontSize: 21,
+ fontWeight: "800",
+ letterSpacing: -0.4,
+ marginHorizontal: 20,
+ marginTop: 18,
+ marginBottom: 13,
+ },
+ songRow: {
+ minHeight: 64,
+ paddingHorizontal: 20,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ pressed: { opacity: 0.62 },
+ trackNumber: {
+ color: palette.secondary,
+ width: 24,
+ textAlign: "center",
+ fontVariant: ["tabular-nums"],
+ },
+ songLabels: { flex: 1, justifyContent: "center" },
+ songTitle: { color: "white", fontSize: 15, fontWeight: "600" },
+ songArtist: { color: palette.secondary, fontSize: 13, marginTop: 3 },
+ rowMenuButton: {
+ width: 36,
+ height: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ rail: { paddingHorizontal: 20, gap: 15 },
+ card: { width: 154 },
+ cardTitle: { color: "white", fontWeight: "700", fontSize: 14, marginTop: 9 },
+ cardSubtitle: { color: palette.secondary, fontSize: 13, marginTop: 3 },
+});
diff --git a/apps/mobile/src/components/pause-glyph.tsx b/apps/mobile/src/components/pause-glyph.tsx
new file mode 100644
index 00000000..eb012f7f
--- /dev/null
+++ b/apps/mobile/src/components/pause-glyph.tsx
@@ -0,0 +1,35 @@
+import { StyleSheet, View } from "react-native";
+
+export function PauseGlyph({
+ color = "white",
+ size = 24,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ const barWidth = Math.max(4, Math.round(size * 0.28));
+ return (
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ alignItems: "stretch",
+ justifyContent: "center",
+ },
+ bar: { height: "100%", borderRadius: 1 },
+});
diff --git a/apps/mobile/src/components/playlist-actions.tsx b/apps/mobile/src/components/playlist-actions.tsx
new file mode 100644
index 00000000..b9f5cfdf
--- /dev/null
+++ b/apps/mobile/src/components/playlist-actions.tsx
@@ -0,0 +1,44 @@
+import { getPlaylist } from "@parson/music-sdk";
+import { ListMusic, Play } from "lucide-react-native";
+import { useRouter } from "expo-router";
+
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import { usePlayer } from "@/providers/player-provider";
+
+export function PlaylistActions({
+ playlistId,
+ name,
+ onClose,
+ open,
+}: {
+ playlistId: number;
+ name?: string;
+ onClose: () => void;
+ open: boolean;
+}) {
+ const router = useRouter();
+ const player = usePlayer();
+ return (
+
+ {
+ onClose();
+ void getPlaylist(playlistId).then((playlist) => {
+ if (playlist.songs[0])
+ player.playSong(playlist.songs[0], playlist.songs);
+ });
+ }}
+ />
+ {
+ onClose();
+ router.push(`/playlist/${playlistId}`);
+ }}
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/playlist-picker.tsx b/apps/mobile/src/components/playlist-picker.tsx
new file mode 100644
index 00000000..9fbe300d
--- /dev/null
+++ b/apps/mobile/src/components/playlist-picker.tsx
@@ -0,0 +1,153 @@
+import {
+ addAlbumToPlaylist,
+ addSongToPlaylist,
+ createPlaylist,
+ getPlaylists,
+} from "@parson/music-sdk";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ListPlus, Plus, RefreshCw } from "lucide-react-native";
+import { useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+
+import { ActionDrawer, DrawerAction } from "@/components/action-drawer";
+import { palette } from "@/constants/colors";
+
+export function PlaylistPicker({
+ albumId,
+ onClose,
+ open,
+ songId,
+}: {
+ albumId?: string;
+ onClose: () => void;
+ open: boolean;
+ songId?: string;
+}) {
+ const client = useQueryClient();
+ const [creating, setCreating] = useState(false);
+ const [addingTo, setAddingTo] = useState(null);
+ const [creatingPlaylist, setCreatingPlaylist] = useState(false);
+ const [name, setName] = useState("");
+ const playlists = useQuery({
+ queryKey: ["playlists"],
+ queryFn: getPlaylists,
+ enabled: open,
+ });
+ const add = async (id: number) => {
+ if (addingTo !== null) return;
+ setAddingTo(id);
+ try {
+ if (songId) await addSongToPlaylist(id, songId);
+ else if (albumId) await addAlbumToPlaylist(id, albumId);
+ onClose();
+ void client.invalidateQueries({ queryKey: ["playlists"] });
+ } finally {
+ setAddingTo(null);
+ }
+ };
+ const create = async () => {
+ if (!name.trim() || creatingPlaylist) return;
+ setCreatingPlaylist(true);
+ try {
+ await createPlaylist(
+ name.trim(),
+ songId ? [songId] : [],
+ songId ? undefined : albumId,
+ );
+ setName("");
+ setCreating(false);
+ onClose();
+ void client.invalidateQueries({ queryKey: ["playlists"] });
+ } finally {
+ setCreatingPlaylist(false);
+ }
+ };
+ return (
+
+ {playlists.isPending ? (
+
+
+ Loading playlists…
+
+ ) : null}
+ {playlists.isError ? (
+ void playlists.refetch()}
+ />
+ ) : null}
+ {playlists.data?.map((playlist) => (
+ void add(playlist.id)}
+ />
+ ))}
+ {creating ? (
+
+ void create()}
+ />
+ void create()}
+ >
+
+ {creatingPlaylist ? "Creating…" : "Create"}
+
+
+
+ ) : (
+ setCreating(true)}
+ />
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ loading: {
+ minHeight: 54,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ paddingHorizontal: 16,
+ },
+ loadingText: { color: palette.secondary },
+ create: { flexDirection: "row", gap: 8, padding: 10 },
+ input: {
+ flex: 1,
+ height: 48,
+ borderRadius: 10,
+ backgroundColor: "#29292e",
+ color: "white",
+ paddingHorizontal: 13,
+ },
+ button: {
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: "white",
+ paddingHorizontal: 18,
+ justifyContent: "center",
+ },
+ buttonText: { color: "black", fontWeight: "800" },
+});
diff --git a/apps/mobile/src/constants/colors.ts b/apps/mobile/src/constants/colors.ts
new file mode 100644
index 00000000..49e08c59
--- /dev/null
+++ b/apps/mobile/src/constants/colors.ts
@@ -0,0 +1,21 @@
+export const palette = {
+ background: "#000000",
+ elevated: "#0c0c0e",
+ elevatedStrong: "#151518",
+ border: "rgba(255,255,255,0.10)",
+ borderStrong: "rgba(255,255,255,0.18)",
+ text: "#ffffff",
+ secondary: "#a1a1aa",
+ muted: "#5f5f69",
+ accent: "#ffffff",
+ accentText: "#000000",
+ danger: "#ef4444",
+ success: "#22c55e",
+} as const;
+
+export const layout = {
+ horizontal: 20,
+ tabBar: 76,
+ miniPlayer: 68,
+ radius: 16,
+} as const;
diff --git a/apps/mobile/src/hooks/use-library-discovery.ts b/apps/mobile/src/hooks/use-library-discovery.ts
new file mode 100644
index 00000000..1ba68b71
--- /dev/null
+++ b/apps/mobile/src/hooks/use-library-discovery.ts
@@ -0,0 +1,29 @@
+import { useEffect, useRef } from "react";
+
+import ParsonDiscovery from "../../modules/parson-discovery";
+import { useSession } from "@/providers/session-provider";
+
+export function useLibraryDiscovery() {
+ const { connect, phase } = useSession();
+ const attempted = useRef(new Set());
+ useEffect(() => {
+ if (phase !== "discovering") return;
+ const subscription = ParsonDiscovery.addListener(
+ "onService",
+ ({ host, port }) => {
+ const origin = `http://${host.includes(":") ? `[${host}]` : host}:${port}`;
+ if (attempted.current.has(origin)) return;
+ attempted.current.add(origin);
+ void connect(origin).then((connected) => {
+ if (!connected)
+ setTimeout(() => attempted.current.delete(origin), 5000);
+ });
+ },
+ );
+ ParsonDiscovery.start();
+ return () => {
+ subscription.remove();
+ ParsonDiscovery.stop();
+ };
+ }, [connect, phase]);
+}
diff --git a/apps/mobile/src/lib/downloads.ts b/apps/mobile/src/lib/downloads.ts
new file mode 100644
index 00000000..58761a8b
--- /dev/null
+++ b/apps/mobile/src/lib/downloads.ts
@@ -0,0 +1,309 @@
+import type { LibraryAlbum, LibrarySong } from "@parson/music-sdk";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { Directory, File, Paths } from "expo-file-system";
+import { useSyncExternalStore } from "react";
+
+import { authorizationHeaders, streamUrl } from "@/lib/runtime";
+
+const safe = (value: string) =>
+ value
+ .replace(/[\\/:*?"<>|]/g, "_")
+ .trim()
+ .slice(0, 100) || "Music";
+
+type DownloadRecord = {
+ albumDownload?: { albumId: string; songIds: string[] };
+ id: string;
+ uri: string;
+ song?: LibrarySong;
+};
+
+const DOWNLOADS_KEY = "parson.downloaded-songs";
+const downloaded = new Map();
+const listeners = new Set<() => void>();
+let hydrated = false;
+let hydrationPromise: Promise | null = null;
+let revision = 0;
+
+const snapshot = () => revision;
+const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+};
+const changed = () => {
+ revision += 1;
+ listeners.forEach((listener) => listener());
+};
+const persist = () =>
+ AsyncStorage.setItem(DOWNLOADS_KEY, JSON.stringify([...downloaded.values()]));
+
+export function useDownloadsRevision() {
+ return useSyncExternalStore(subscribe, snapshot, snapshot);
+}
+
+export async function hydrateDownloads() {
+ if (hydrated) return;
+ if (!hydrationPromise) {
+ hydrationPromise = (async () => {
+ try {
+ const stored = await AsyncStorage.getItem(DOWNLOADS_KEY);
+ const raw = stored ? (JSON.parse(stored) as unknown[]) : [];
+ let needsMigration = false;
+ for (const item of raw) {
+ const legacy = Array.isArray(item) ? item : null;
+ const value = legacy
+ ? {
+ albumDownload: undefined,
+ id: legacy[0],
+ song: undefined,
+ uri: legacy[1],
+ }
+ : (item as Partial);
+ if (
+ typeof value.id === "string" &&
+ typeof value.uri === "string" &&
+ new File(value.uri).exists
+ ) {
+ downloaded.set(value.id, {
+ albumDownload: value.albumDownload,
+ id: value.id,
+ uri: value.uri,
+ song: "song" in value ? value.song : undefined,
+ });
+ needsMigration ||= !!legacy;
+ } else {
+ needsMigration = true;
+ }
+ }
+ if (needsMigration) await persist();
+ } catch {
+ } finally {
+ hydrated = true;
+ changed();
+ }
+ })();
+ }
+ await hydrationPromise;
+}
+
+export function downloadedSongUri(songId: string) {
+ return downloaded.get(songId)?.uri ?? null;
+}
+
+export function isSongDownloaded(songId: string) {
+ return downloaded.has(songId);
+}
+
+export function downloadedRecords() {
+ return [...downloaded.values()];
+}
+
+export type DownloadedLibraryItem =
+ { kind: "album"; album: LibraryAlbum } | { kind: "song"; song: LibrarySong };
+
+const downloadParent = (uri: string) => {
+ const path = uri.split("?", 1)[0]?.replace(/\/+$/, "") ?? "";
+ return path.slice(0, Math.max(0, path.lastIndexOf("/")));
+};
+
+const isIndividualSongsFolder = (parent: string) =>
+ /\/Parson\/Songs$/i.test(decodeURI(parent));
+
+export function groupDownloadedLibrary(
+ songs: LibrarySong[],
+): DownloadedLibraryItem[] {
+ const downloadedById = new Map(songs.map((song) => [song.id, song]));
+ const declaredAlbums = new Map();
+ downloaded.forEach((record) => {
+ const batch = record.albumDownload;
+ if (
+ batch?.albumId &&
+ batch.songIds.length &&
+ batch.songIds.every((id) => downloadedById.has(id))
+ ) {
+ declaredAlbums.set(batch.albumId, batch.songIds);
+ }
+ });
+ const legacyAlbumFolders = new Map>();
+ downloaded.forEach((record) => {
+ const song = record.song;
+ const albumId = song?.album_object?.id;
+ const parent = downloadParent(record.uri);
+ if (
+ !song ||
+ !albumId ||
+ !downloadedById.has(song.id) ||
+ !parent ||
+ isIndividualSongsFolder(parent)
+ )
+ return;
+ const folders =
+ legacyAlbumFolders.get(albumId) ?? new Map();
+ const songIds = folders.get(parent) ?? [];
+ songIds.push(song.id);
+ folders.set(parent, songIds);
+ legacyAlbumFolders.set(albumId, folders);
+ });
+ legacyAlbumFolders.forEach((folders, albumId) => {
+ if (declaredAlbums.has(albumId)) return;
+ const largestFolder = [...folders.values()].sort(
+ (left, right) => right.length - left.length,
+ )[0];
+ // Legacy album downloads share a folder but have no batch marker.
+ if (largestFolder && largestFolder.length > 1) {
+ declaredAlbums.set(
+ albumId,
+ largestFolder.sort(
+ (left, right) =>
+ (downloadedById.get(left)?.track_number ?? 0) -
+ (downloadedById.get(right)?.track_number ?? 0),
+ ),
+ );
+ }
+ });
+ const completeAlbums = new Map();
+
+ for (const song of songs) {
+ const album = song.album_object;
+ if (!album?.id || completeAlbums.has(album.id)) continue;
+ const declaredSongIds = declaredAlbums.get(album.id);
+ const albumSongIds = album.songs?.map((track) => track.id) ?? [];
+ const completeSongIds = declaredSongIds ?? albumSongIds;
+ if (
+ !completeSongIds.length ||
+ !completeSongIds.every((id) => downloadedById.has(id))
+ )
+ continue;
+ completeAlbums.set(album.id, {
+ ...album,
+ artist_object: song.artist_object,
+ songs: completeSongIds.flatMap((id) => {
+ const track = downloadedById.get(id);
+ return track ? [track] : [];
+ }),
+ });
+ }
+
+ const emittedAlbums = new Set();
+ return songs.flatMap((song): DownloadedLibraryItem[] => {
+ const albumId = song.album_object?.id;
+ const album = albumId ? completeAlbums.get(albumId) : undefined;
+ if (!album) return [{ kind: "song", song }];
+ if (emittedAlbums.has(album.id)) return [];
+ emittedAlbums.add(album.id);
+ return [{ kind: "album", album }];
+ });
+}
+
+export async function enrichDownloadedSongs(songs: LibrarySong[]) {
+ let didChange = false;
+ for (const song of songs) {
+ const record = downloaded.get(song.id);
+ if (record && !record.song) {
+ downloaded.set(song.id, { ...record, song });
+ didChange = true;
+ }
+ }
+ if (didChange) {
+ await persist();
+ changed();
+ }
+}
+
+async function rememberDownload(
+ song: LibrarySong,
+ uri: string,
+ notify = true,
+ albumDownload?: DownloadRecord["albumDownload"],
+) {
+ downloaded.set(song.id, { albumDownload, id: song.id, uri, song });
+ if (notify) {
+ await persist();
+ changed();
+ }
+}
+
+export async function removeDownload(songId: string) {
+ await removeDownloads([songId]);
+}
+
+export async function removeDownloads(songIds: string[]) {
+ const records = songIds.flatMap((id) => {
+ const record = downloaded.get(id);
+ if (!record) return [];
+ downloaded.delete(id);
+ return [record];
+ });
+ if (!records.length) return;
+ changed();
+ for (const record of records) {
+ try {
+ const file = new File(record.uri);
+ if (file.exists) file.delete();
+ } catch {}
+ }
+ await persist();
+}
+
+export async function downloadAlbum(
+ name: string,
+ songs: LibrarySong[],
+ onProgress?: (done: number) => void,
+) {
+ const directory = new Directory(Paths.document, "Parson", safe(name));
+ const albumDownload = songs[0]?.album_object?.id
+ ? {
+ albumId: songs[0].album_object.id,
+ songIds: songs.map((song) => song.id),
+ }
+ : undefined;
+ directory.create({ intermediates: true, idempotent: true });
+ try {
+ for (let index = 0; index < songs.length; index += 1) {
+ const song = songs[index];
+ if (!song) continue;
+ const extension =
+ song.path
+ .split(".")
+ .pop()
+ ?.toLowerCase()
+ .match(/^[a-z0-9]{2,5}$/)?.[0] ?? "mp3";
+ const destination = new File(
+ directory,
+ `${String(index + 1).padStart(2, "0")} ${safe(song.name)}.${extension}`,
+ );
+ const saved = await File.downloadFileAsync(
+ streamUrl(song.id),
+ destination,
+ { headers: authorizationHeaders(), idempotent: true },
+ );
+ await rememberDownload(song, saved.uri, false, albumDownload);
+ onProgress?.(index + 1);
+ }
+ } finally {
+ await persist();
+ changed();
+ }
+ return directory.uri;
+}
+
+export async function downloadSong(song: LibrarySong) {
+ const directory = new Directory(Paths.document, "Parson", "Songs");
+ directory.create({ intermediates: true, idempotent: true });
+ const extension =
+ song.path
+ .split(".")
+ .pop()
+ ?.toLowerCase()
+ .match(/^[a-z0-9]{2,5}$/)?.[0] ?? "mp3";
+ const destination = new File(
+ directory,
+ `${safe(song.artist)} - ${safe(song.name)}.${extension}`,
+ );
+ const saved = await File.downloadFileAsync(streamUrl(song.id), destination, {
+ headers: authorizationHeaders(),
+ idempotent: true,
+ });
+ await rememberDownload(song, saved.uri);
+ return saved;
+}
diff --git a/apps/mobile/src/lib/format.ts b/apps/mobile/src/lib/format.ts
new file mode 100644
index 00000000..8ff3bc8b
--- /dev/null
+++ b/apps/mobile/src/lib/format.ts
@@ -0,0 +1,7 @@
+export function formatCollectionDuration(seconds: number) {
+ const minutes = Math.max(1, Math.round(seconds / 60));
+ if (minutes < 60) return `${minutes} min`;
+ const hours = Math.floor(minutes / 60);
+ const remainder = minutes % 60;
+ return remainder ? `${hours} hr ${remainder} min` : `${hours} hr`;
+}
diff --git a/apps/mobile/src/lib/playable-song.ts b/apps/mobile/src/lib/playable-song.ts
new file mode 100644
index 00000000..d7f3f905
--- /dev/null
+++ b/apps/mobile/src/lib/playable-song.ts
@@ -0,0 +1,66 @@
+import type { CombinedItem, LibrarySong } from "@parson/music-sdk";
+
+type CatalogSong = {
+ id: string;
+ name: string;
+ artistId?: string;
+ artistName: string;
+ albumId?: string;
+ albumName: string;
+ coverPath?: string;
+ duration?: number;
+ path?: string;
+};
+
+export function playableCatalogSong(song: CatalogSong): LibrarySong {
+ const artistId = song.artistId ?? "";
+ const albumId = song.albumId ?? "";
+ return {
+ id: song.id,
+ album_id: albumId,
+ artist_id: artistId,
+ name: song.name,
+ artist: song.artistName,
+ contributing_artists: [],
+ contributing_artist_ids: [],
+ track_number: 0,
+ path: song.path ?? "",
+ duration: song.duration ?? 0,
+ artist_object: {
+ id: artistId,
+ name: song.artistName,
+ icon_url: "",
+ followers: 0,
+ albums: [],
+ featured_on_album_ids: [],
+ description: "",
+ },
+ album_object: {
+ id: albumId,
+ name: song.albumName,
+ cover_url: song.coverPath ?? "",
+ songs: [],
+ first_release_date: "",
+ musicbrainz_id: "",
+ wikidata_id: null,
+ primary_type: "Album",
+ description: "",
+ contributing_artists: [],
+ contributing_artists_ids: [],
+ },
+ };
+}
+
+export function playableSearchSong(item: CombinedItem): LibrarySong {
+ return playableCatalogSong({
+ id: item.id,
+ name: item.song_object?.name ?? item.name,
+ duration: item.song_object?.duration,
+ path: item.song_object?.path,
+ artistId: item.artist_object?.id,
+ artistName: item.artist_object?.name ?? "Unknown artist",
+ albumId: item.album_object?.id,
+ albumName: item.album_object?.name ?? "Unknown album",
+ coverPath: item.album_object?.cover_url,
+ });
+}
diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts
new file mode 100644
index 00000000..30e03d99
--- /dev/null
+++ b/apps/mobile/src/lib/runtime.ts
@@ -0,0 +1,74 @@
+import { configureApiRuntime } from "@parson/music-sdk";
+
+type RuntimeSnapshot = {
+ origin: string | null;
+ token: string | null;
+ unauthorized: (() => void) | null;
+ tokenChanged: ((token: string) => void) | null;
+};
+
+const runtime: RuntimeSnapshot = {
+ origin: null,
+ token: null,
+ unauthorized: null,
+ tokenChanged: null,
+};
+
+configureApiRuntime({
+ getAccessToken: () => runtime.token,
+ getServerUrl: () => runtime.origin,
+ onAccessToken: (token) => {
+ runtime.token = token;
+ runtime.tokenChanged?.(token);
+ },
+ onUnauthorized: () => runtime.unauthorized?.(),
+});
+
+export function configureNativeRuntime(next: Partial) {
+ Object.assign(runtime, next);
+}
+
+export function normalizeOrigin(value: string) {
+ const candidate = value.trim().replace(/\/+$/, "");
+ const withScheme = /^https?:\/\//i.test(candidate)
+ ? candidate
+ : `http://${candidate}`;
+ const parsed = new URL(withScheme);
+ return parsed.origin;
+}
+
+export function apiUrl(path: string) {
+ if (!runtime.origin) throw new Error("No Parson library is connected.");
+ return `${runtime.origin}/api/v1/${path.replace(/^\/+/, "")}`;
+}
+
+export function authenticatedUrl(path: string) {
+ return apiUrl(path);
+}
+
+export function imageUrl(path?: string | null) {
+ const image = path?.trim();
+ const normalized = image?.toLowerCase() ?? "";
+ if (
+ !runtime.origin ||
+ !image ||
+ normalized === "snf.png" ||
+ normalized.endsWith("/snf.png") ||
+ normalized.includes("snf.png?")
+ )
+ return null;
+ if (/^https?:\/\//i.test(image)) return image;
+ return `${runtime.origin}/media/images/${encodeURIComponent(image)}`;
+}
+
+export function streamUrl(songId: string, bitrate = 0) {
+ const url = new URL(
+ apiUrl(`media/songs/${encodeURIComponent(songId)}/stream`),
+ );
+ url.searchParams.set("bitrate", String(bitrate));
+ return url.toString();
+}
+
+export function authorizationHeaders(): Record {
+ return runtime.token ? { Authorization: `Bearer ${runtime.token}` } : {};
+}
diff --git a/apps/mobile/src/providers/player-provider.tsx b/apps/mobile/src/providers/player-provider.tsx
new file mode 100644
index 00000000..cebbc715
--- /dev/null
+++ b/apps/mobile/src/providers/player-provider.tsx
@@ -0,0 +1,861 @@
+import {
+ fillReverbImpulseChannel,
+ getPlayerAudioPreset,
+ playerAudioPresets,
+ reverbImpulseLength,
+ type LibrarySong,
+ type PlayerAudioPresetId,
+} from "@parson/music-sdk";
+import * as Haptics from "expo-haptics";
+import {
+ createAudioPlayer,
+ setAudioModeAsync,
+ type AudioPlayer as ExpoAudioPlayer,
+} from "expo-audio";
+import {
+ Audio,
+ AudioContext,
+ AudioManager,
+ PlaybackNotificationManager,
+ type AudioTagHandle,
+ type ConvolverNode,
+ type GainNode,
+ type MediaElementAudioSourceNode,
+} from "react-native-audio-api";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type Dispatch,
+ type PropsWithChildren,
+ type RefObject,
+ type SetStateAction,
+} from "react";
+import { Platform } from "react-native";
+
+import { authorizationHeaders, imageUrl, streamUrl } from "@/lib/runtime";
+import { downloadedSongUri, hydrateDownloads } from "@/lib/downloads";
+import { useSession } from "@/providers/session-provider";
+
+type RepeatMode = "none" | "one" | "all";
+export type AudioPreset = PlayerAudioPresetId;
+
+type PlaybackSource = {
+ autoplay: boolean;
+ key: number;
+ retryCount: number;
+ value: { uri: string; headers?: Record };
+};
+
+type NativeAudioGraph = {
+ convolver: ConvolverNode;
+ dryGain: GainNode;
+ source: MediaElementAudioSourceNode | null;
+ wetConnected: boolean;
+ wetGain: GainNode;
+};
+
+type PlaybackControls = {
+ cleanup: () => void;
+ interrupt: () => boolean;
+ next: () => void;
+ pause: () => void;
+ play: () => void;
+ previous: () => void;
+ seek: (seconds: number) => void;
+};
+
+type PlayerContextValue = {
+ current: LibrarySong | null;
+ currentIndex: number;
+ isBuffering: boolean;
+ isPlaying: boolean;
+ queue: LibrarySong[];
+ repeat: RepeatMode;
+ audioPreset: AudioPreset;
+ audioPresetsEnabled: boolean;
+ playSong: (song: LibrarySong, queue?: LibrarySong[]) => void;
+ playAt: (index: number) => void;
+ addNext: (song: LibrarySong) => void;
+ addToQueue: (songs: LibrarySong[]) => void;
+ toggle: () => void;
+ next: () => void;
+ previous: () => void;
+ seek: (seconds: number) => void;
+ cycleRepeat: () => void;
+ setAudioPreset: (preset: AudioPreset) => void;
+};
+
+const PlayerContext = createContext(null);
+const PlayerPositionContext = createContext({ currentTime: 0, duration: 0 });
+
+function buildReverbImpulse(context: AudioContext) {
+ const length = reverbImpulseLength(context.sampleRate);
+ const impulse = context.createBuffer(2, length, context.sampleRate);
+ for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
+ fillReverbImpulseChannel(impulse.getChannelData(channel), channel);
+ }
+ return impulse;
+}
+
+function disconnectSource(graph: NativeAudioGraph) {
+ if (!graph.source) return;
+ try {
+ graph.source.disconnect();
+ } catch {}
+ graph.source = null;
+ graph.wetConnected = false;
+}
+
+function useNativePlaybackControls(controls: PlaybackControls) {
+ const controlsRef = useRef(controls);
+ const interruptedWhilePlaying = useRef(false);
+
+ useEffect(() => {
+ controlsRef.current = controls;
+ }, [controls]);
+
+ useEffect(() => {
+ void hydrateDownloads();
+ if (Platform.OS === "android") return;
+ AudioManager.setAudioSessionOptions({
+ iosCategory: "playback",
+ iosMode: "default",
+ iosNotifyOthersOnDeactivation: true,
+ });
+ AudioManager.observeAudioInterruptions("gain");
+
+ const interruption = AudioManager.addSystemEventListener(
+ "interruption",
+ ({ type, shouldResume }) => {
+ if (type === "began") {
+ interruptedWhilePlaying.current = controlsRef.current.interrupt();
+ return;
+ }
+ if (shouldResume && interruptedWhilePlaying.current) {
+ interruptedWhilePlaying.current = false;
+ controlsRef.current.play();
+ }
+ },
+ );
+ const subscriptions = [
+ interruption,
+ PlaybackNotificationManager.addEventListener(
+ "playbackNotificationPlay",
+ () => controlsRef.current.play(),
+ ),
+ PlaybackNotificationManager.addEventListener(
+ "playbackNotificationPause",
+ () => controlsRef.current.pause(),
+ ),
+ PlaybackNotificationManager.addEventListener(
+ "playbackNotificationNextTrack",
+ () => controlsRef.current.next(),
+ ),
+ PlaybackNotificationManager.addEventListener(
+ "playbackNotificationPreviousTrack",
+ () => controlsRef.current.previous(),
+ ),
+ PlaybackNotificationManager.addEventListener(
+ "playbackNotificationSeekTo",
+ ({ value }) => controlsRef.current.seek(value),
+ ),
+ ];
+
+ return () => {
+ subscriptions.forEach((subscription) => subscription?.remove());
+ controlsRef.current.cleanup();
+ void PlaybackNotificationManager.hide().catch(() => {});
+ void AudioManager.setAudioSessionActivity(false).catch(() => {});
+ };
+ }, []);
+}
+
+function usePlaybackNotification({
+ current,
+ currentTime,
+ duration,
+ isPlaying,
+ rate,
+}: {
+ current: LibrarySong | null;
+ currentTime: number;
+ duration: number;
+ isPlaying: boolean;
+ rate: number;
+}) {
+ const controlsEnabled = useRef(false);
+ const permissionRequested = useRef(false);
+ const notificationUpdates = useRef>(Promise.resolve());
+ const elapsedTime = Math.floor(currentTime / 30) * 30;
+
+ useEffect(() => {
+ if (Platform.OS === "android") return;
+ const artwork = current
+ ? imageUrl(current.album_object?.cover_url)
+ : undefined;
+
+ // Older Android versions require serialized notification updates.
+ notificationUpdates.current = notificationUpdates.current
+ .catch(() => {})
+ .then(async () => {
+ if (!current) {
+ controlsEnabled.current = false;
+ await PlaybackNotificationManager.hide();
+ return;
+ }
+ if (Platform.OS === "android" && !permissionRequested.current) {
+ permissionRequested.current = true;
+ await AudioManager.requestNotificationPermissions();
+ }
+ if (!controlsEnabled.current) {
+ await PlaybackNotificationManager.enableControl("play", true);
+ await PlaybackNotificationManager.enableControl("pause", true);
+ await PlaybackNotificationManager.enableControl(
+ "previousTrack",
+ true,
+ );
+ await PlaybackNotificationManager.enableControl("nextTrack", true);
+ await PlaybackNotificationManager.enableControl("seekTo", true);
+ controlsEnabled.current = true;
+ }
+ await PlaybackNotificationManager.show({
+ title: current.name,
+ artist: current.artist,
+ album: current.album_object?.name,
+ artwork: artwork ? { uri: artwork } : undefined,
+ duration,
+ elapsedTime,
+ speed: rate,
+ state: isPlaying ? "playing" : "paused",
+ });
+ })
+ .catch(() => {
+ controlsEnabled.current = false;
+ });
+ }, [current, duration, elapsedTime, isPlaying, rate]);
+}
+
+function schedulePlaybackRetry(
+ source: PlaybackSource,
+ desiredPlayingRef: RefObject,
+ sourceSequenceRef: RefObject,
+ setPlaybackSource: Dispatch>,
+) {
+ if (
+ !desiredPlayingRef.current ||
+ source.retryCount >= 2 ||
+ !/^https?:\/\//i.test(source.value.uri)
+ ) {
+ return false;
+ }
+ const failedKey = source.key;
+ setTimeout(
+ () => {
+ setPlaybackSource((activeSource) =>
+ activeSource?.key === failedKey && desiredPlayingRef.current
+ ? {
+ ...activeSource,
+ key: ++sourceSequenceRef.current,
+ retryCount: activeSource.retryCount + 1,
+ }
+ : activeSource,
+ );
+ },
+ 600 * (source.retryCount + 1),
+ );
+ return true;
+}
+
+function useAndroidAudioOutput({
+ current,
+ playbackSource,
+ androidPlayerRef,
+ playbackSourceRef,
+ desiredPlayingRef,
+ endedHandlerRef,
+ sourceSequenceRef,
+ setPlaybackSource,
+ setCurrentTime,
+ setDuration,
+ setIsBuffering,
+ setIsPlaying,
+}: {
+ current: LibrarySong | null;
+ playbackSource: PlaybackSource | null;
+ androidPlayerRef: RefObject;
+ playbackSourceRef: RefObject;
+ desiredPlayingRef: RefObject;
+ endedHandlerRef: RefObject<() => void>;
+ sourceSequenceRef: RefObject;
+ setPlaybackSource: Dispatch>;
+ setCurrentTime: Dispatch>;
+ setDuration: Dispatch>;
+ setIsBuffering: Dispatch>;
+ setIsPlaying: Dispatch>;
+}) {
+ useEffect(() => {
+ if (Platform.OS !== "android") return;
+ void setAudioModeAsync({
+ interruptionMode: "doNotMix",
+ playsInSilentMode: true,
+ shouldPlayInBackground: true,
+ }).catch((cause) =>
+ console.error("Could not configure Android audio", cause),
+ );
+
+ const player = createAudioPlayer(null, {
+ keepAudioSessionActive: true,
+ preferredForwardBufferDuration: 15,
+ updateInterval: 250,
+ });
+ androidPlayerRef.current = player;
+ let finishedSourceKey = -1;
+ const subscription = player.addListener(
+ "playbackStatusUpdate",
+ (status) => {
+ if (Number.isFinite(status.currentTime))
+ setCurrentTime(status.currentTime);
+ if (Number.isFinite(status.duration) && status.duration > 0) {
+ setDuration(status.duration);
+ }
+ setIsBuffering(
+ status.isBuffering || (desiredPlayingRef.current && !status.isLoaded),
+ );
+ // Ignore stale native state while replacing the source.
+ if (status.playing) setIsPlaying(true);
+ else if (!desiredPlayingRef.current) setIsPlaying(false);
+
+ const source = playbackSourceRef.current;
+ if (status.error && source) {
+ console.error("Could not load Android audio source", status.error);
+ if (
+ !schedulePlaybackRetry(
+ source,
+ desiredPlayingRef,
+ sourceSequenceRef,
+ setPlaybackSource,
+ )
+ ) {
+ desiredPlayingRef.current = false;
+ setIsBuffering(false);
+ setIsPlaying(false);
+ }
+ }
+ if (
+ status.didJustFinish &&
+ source &&
+ finishedSourceKey !== source.key
+ ) {
+ finishedSourceKey = source.key;
+ endedHandlerRef.current();
+ }
+ },
+ );
+ return () => {
+ subscription.remove();
+ player.clearLockScreenControls();
+ player.remove();
+ androidPlayerRef.current = null;
+ };
+ }, [
+ androidPlayerRef,
+ desiredPlayingRef,
+ endedHandlerRef,
+ playbackSourceRef,
+ setCurrentTime,
+ setDuration,
+ setIsBuffering,
+ setIsPlaying,
+ setPlaybackSource,
+ sourceSequenceRef,
+ ]);
+
+ useEffect(() => {
+ if (Platform.OS !== "android" || !playbackSource) return;
+ const player = androidPlayerRef.current;
+ if (!player) return;
+ setIsBuffering(true);
+ player.replace(playbackSource.value);
+ player.loop = false;
+ if (current) {
+ player.setActiveForLockScreen(true, {
+ albumTitle: current.album_object?.name,
+ artist: current.artist,
+ artworkUrl: imageUrl(current.album_object?.cover_url) ?? undefined,
+ title: current.name,
+ });
+ }
+ if (playbackSource.autoplay && desiredPlayingRef.current) player.play();
+ }, [
+ androidPlayerRef,
+ current,
+ desiredPlayingRef,
+ playbackSource,
+ setIsBuffering,
+ ]);
+}
+
+function BrowserAudioOutput({
+ playbackSource,
+ currentRate,
+ preservePitch,
+ audioRef,
+ audioContext,
+ desiredPlayingRef,
+ sourceSequenceRef,
+ setPlaybackSource,
+ setCurrentTime,
+ setIsBuffering,
+ setIsPlaying,
+ routeLoadedSource,
+ handleEnded,
+}: {
+ playbackSource: PlaybackSource;
+ currentRate: number;
+ preservePitch: boolean;
+ audioRef: RefObject;
+ audioContext: AudioContext;
+ desiredPlayingRef: RefObject;
+ sourceSequenceRef: RefObject;
+ setPlaybackSource: Dispatch>;
+ setCurrentTime: Dispatch>;
+ setIsBuffering: Dispatch>;
+ setIsPlaying: Dispatch>;
+ routeLoadedSource: () => void;
+ handleEnded: () => void;
+}) {
+ return (
+ setIsBuffering(true)}
+ onLoad={routeLoadedSource}
+ onError={(error) => {
+ console.error("Could not load audio source", error);
+ if (
+ schedulePlaybackRetry(
+ playbackSource,
+ desiredPlayingRef,
+ sourceSequenceRef,
+ setPlaybackSource,
+ )
+ ) {
+ setIsBuffering(true);
+ return;
+ }
+ desiredPlayingRef.current = false;
+ setIsBuffering(false);
+ setIsPlaying(false);
+ }}
+ onPositionChange={setCurrentTime}
+ onEnded={handleEnded}
+ onPlay={() => {
+ desiredPlayingRef.current = true;
+ setIsBuffering(false);
+ setIsPlaying(true);
+ }}
+ onPause={() => setIsPlaying(false)}
+ />
+ );
+}
+
+export function PlayerProvider({ children }: PropsWithChildren) {
+ const session = useSession();
+ const [audioContext] = useState(() => new AudioContext());
+ const audioRef = useRef(null);
+ const androidPlayerRef = useRef(null);
+ const graphRef = useRef(null);
+ const sourceSequence = useRef(0);
+ const desiredPlaying = useRef(false);
+ const endedHandlerRef = useRef<() => void>(() => {});
+ const playbackSourceRef = useRef(null);
+
+ const [queue, setQueue] = useState([]);
+ const [currentIndex, setCurrentIndex] = useState(-1);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [isBuffering, setIsBuffering] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [repeat, setRepeat] = useState("none");
+ const [audioPreset, setAudioPresetState] = useState("original");
+ const [playbackSource, setPlaybackSource] = useState(
+ null,
+ );
+
+ const current = queue[currentIndex] ?? null;
+ const preset = getPlayerAudioPreset(audioPreset);
+ const audioPresetsEnabled = Platform.OS !== "android";
+ const effectivePreset = audioPresetsEnabled
+ ? preset
+ : getPlayerAudioPreset("original");
+
+ const ensureGraph = useCallback(() => {
+ if (graphRef.current) return graphRef.current;
+ const dryGain = audioContext.createGain();
+ const wetGain = audioContext.createGain();
+ const convolver = audioContext.createConvolver();
+ convolver.buffer = buildReverbImpulse(audioContext);
+ dryGain.gain.value = 1;
+ wetGain.gain.value = 0;
+ dryGain.connect(audioContext.destination);
+ convolver.connect(wetGain);
+ wetGain.connect(audioContext.destination);
+ graphRef.current = {
+ convolver,
+ dryGain,
+ source: null,
+ wetConnected: false,
+ wetGain,
+ };
+ return graphRef.current;
+ }, [audioContext]);
+
+ const applyPreset = useCallback(
+ (presetId: AudioPreset) => {
+ const next = getPlayerAudioPreset(presetId);
+ audioRef.current?.setPlaybackRate(next.rate);
+ const graph = graphRef.current;
+ if (!graph) return;
+ const now = audioContext.currentTime;
+ graph.dryGain.gain.setTargetAtTime(next.dry, now, 0.025);
+ graph.wetGain.gain.setTargetAtTime(next.wet, now, 0.025);
+ if (next.wet > 0 && graph.source && !graph.wetConnected) {
+ graph.source.connect(graph.convolver);
+ graph.wetConnected = true;
+ } else if (next.wet === 0 && graph.source && graph.wetConnected) {
+ try {
+ graph.source.disconnect(graph.convolver);
+ } catch {}
+ graph.wetConnected = false;
+ }
+ },
+ [audioContext],
+ );
+
+ const routeLoadedSource = useCallback(() => {
+ setIsBuffering(false);
+ const audio = audioRef.current;
+ if (!audio || Platform.OS === "web") return;
+ const playWhenRequested = () => {
+ if (!desiredPlaying.current) return;
+ void AudioManager.setAudioSessionActivity(true).catch(() => {});
+ audio.play();
+ };
+ // Older Android audio stacks underrun with the effects graph.
+ if (Platform.OS === "android") {
+ audio.setPlaybackRate(1);
+ playWhenRequested();
+ return;
+ }
+ try {
+ const graph = ensureGraph();
+ disconnectSource(graph);
+ const source = audioContext.createMediaElementSource(audio);
+ source.connect(graph.dryGain);
+ graph.source = source;
+ applyPreset(audioPreset);
+ playWhenRequested();
+ } catch (cause) {
+ setIsPlaying(false);
+ desiredPlaying.current = false;
+ console.error("Could not initialize native convolution audio", cause);
+ }
+ }, [applyPreset, audioContext, audioPreset, ensureGraph]);
+
+ const sourceFor = useCallback(
+ (song: LibrarySong) => {
+ const local = downloadedSongUri(song.id);
+ const bitrate = session.claims?.bitrate ?? 0;
+ return {
+ // Native decoding expects a decoded path, not an Expo file URI.
+ uri: local ? decodeURI(local) : streamUrl(song.id, bitrate),
+ headers: local ? undefined : authorizationHeaders(),
+ };
+ },
+ [session.claims?.bitrate],
+ );
+
+ const loadAt = useCallback(
+ (songs: LibrarySong[], index: number, autoplay = true) => {
+ const song = songs[index];
+ if (!song) return;
+ setQueue(songs);
+ setCurrentIndex(index);
+ setCurrentTime(0);
+ setDuration(Math.max(0, song.duration || 0));
+ setIsBuffering(true);
+ setIsPlaying(autoplay);
+ desiredPlaying.current = autoplay;
+ setPlaybackSource({
+ autoplay,
+ key: ++sourceSequence.current,
+ retryCount: 0,
+ value: sourceFor(song),
+ });
+ },
+ [sourceFor],
+ );
+
+ const playSong = useCallback(
+ (song: LibrarySong, songs = [song]) => {
+ const index = Math.max(
+ 0,
+ songs.findIndex((item) => item.id === song.id),
+ );
+ loadAt(songs, index);
+ },
+ [loadAt],
+ );
+
+ const playAt = useCallback(
+ (index: number) => loadAt(queue, index),
+ [loadAt, queue],
+ );
+
+ const addNext = useCallback(
+ (song: LibrarySong) => {
+ setQueue((items) => {
+ const insert = Math.max(0, currentIndex + 1);
+ return [...items.slice(0, insert), song, ...items.slice(insert)];
+ });
+ },
+ [currentIndex],
+ );
+
+ const addToQueue = useCallback((songs: LibrarySong[]) => {
+ if (songs.length) setQueue((items) => [...items, ...songs]);
+ }, []);
+
+ const play = useCallback(() => {
+ if (!current) return;
+ desiredPlaying.current = true;
+ setIsPlaying(true);
+ if (Platform.OS === "android") {
+ androidPlayerRef.current?.play();
+ return;
+ }
+ if (isBuffering) return;
+ void AudioManager.setAudioSessionActivity(true).catch(() => {});
+ audioRef.current?.play();
+ }, [current, isBuffering]);
+
+ const pause = useCallback(() => {
+ desiredPlaying.current = false;
+ if (Platform.OS === "android") {
+ androidPlayerRef.current?.pause();
+ setIsPlaying(false);
+ return;
+ }
+ audioRef.current?.pause();
+ setIsPlaying(false);
+ }, []);
+
+ const interrupt = useCallback(() => {
+ const wasPlaying = desiredPlaying.current;
+ if (Platform.OS === "android") {
+ androidPlayerRef.current?.pause();
+ setIsPlaying(false);
+ return wasPlaying;
+ }
+ audioRef.current?.pause();
+ setIsPlaying(false);
+ return wasPlaying;
+ }, []);
+
+ const next = useCallback(() => {
+ if (!queue.length) return;
+ const nextIndex = currentIndex + 1;
+ if (nextIndex < queue.length) loadAt(queue, nextIndex);
+ else if (repeat === "all") loadAt(queue, 0);
+ else {
+ desiredPlaying.current = false;
+ setIsPlaying(false);
+ }
+ }, [currentIndex, loadAt, queue, repeat]);
+
+ const seek = useCallback(
+ (seconds: number) => {
+ const target = Math.max(0, Math.min(seconds, duration || seconds));
+ if (Platform.OS === "android") {
+ void androidPlayerRef.current?.seekTo(target);
+ } else {
+ audioRef.current?.seekToTime(target);
+ }
+ setCurrentTime(target);
+ },
+ [duration],
+ );
+
+ const previous = useCallback(() => {
+ if (currentTime > 3) {
+ seek(0);
+ return;
+ }
+ const previousIndex = currentIndex - 1;
+ if (previousIndex >= 0) loadAt(queue, previousIndex);
+ else seek(0);
+ }, [currentIndex, currentTime, loadAt, queue, seek]);
+
+ const toggle = useCallback(() => {
+ void Haptics.selectionAsync();
+ if (desiredPlaying.current) pause();
+ else play();
+ }, [pause, play]);
+
+ const setAudioPreset = useCallback(
+ (presetId: AudioPreset) => {
+ if (!audioPresetsEnabled) return;
+ if (presetId === audioPreset) return;
+ setAudioPresetState(presetId);
+ applyPreset(presetId);
+ },
+ [applyPreset, audioPreset, audioPresetsEnabled],
+ );
+
+ const cycleRepeat = useCallback(() => {
+ setRepeat((value) =>
+ value === "none" ? "all" : value === "all" ? "one" : "none",
+ );
+ }, []);
+
+ const handleEnded = useCallback(() => {
+ setIsPlaying(false);
+ if (repeat === "one") loadAt(queue, currentIndex);
+ else next();
+ }, [currentIndex, loadAt, next, queue, repeat]);
+
+ useEffect(() => {
+ endedHandlerRef.current = handleEnded;
+ }, [handleEnded]);
+
+ useEffect(() => {
+ playbackSourceRef.current = playbackSource;
+ }, [playbackSource]);
+
+ useAndroidAudioOutput({
+ current,
+ playbackSource,
+ androidPlayerRef,
+ playbackSourceRef,
+ desiredPlayingRef: desiredPlaying,
+ endedHandlerRef,
+ sourceSequenceRef: sourceSequence,
+ setPlaybackSource,
+ setCurrentTime,
+ setDuration,
+ setIsBuffering,
+ setIsPlaying,
+ });
+
+ const cleanup = useCallback(() => {
+ if (graphRef.current) disconnectSource(graphRef.current);
+ }, []);
+
+ const playbackControls = useMemo(
+ () => ({ cleanup, interrupt, next, pause, play, previous, seek }),
+ [cleanup, interrupt, next, pause, play, previous, seek],
+ );
+ useNativePlaybackControls(playbackControls);
+ usePlaybackNotification({
+ current,
+ currentTime,
+ duration,
+ isPlaying,
+ rate: effectivePreset.rate,
+ });
+
+ const value = useMemo(
+ () => ({
+ current,
+ currentIndex,
+ isBuffering,
+ isPlaying,
+ queue,
+ repeat,
+ audioPreset,
+ audioPresetsEnabled,
+ playSong,
+ playAt,
+ addNext,
+ addToQueue,
+ toggle,
+ next,
+ previous,
+ seek,
+ cycleRepeat,
+ setAudioPreset,
+ }),
+ [
+ addNext,
+ addToQueue,
+ audioPreset,
+ audioPresetsEnabled,
+ current,
+ currentIndex,
+ cycleRepeat,
+ isBuffering,
+ isPlaying,
+ next,
+ playAt,
+ playSong,
+ previous,
+ queue,
+ repeat,
+ seek,
+ setAudioPreset,
+ toggle,
+ ],
+ );
+ const position = useMemo(
+ () => ({ currentTime, duration }),
+ [currentTime, duration],
+ );
+
+ return (
+
+
+ {children}
+
+ {playbackSource && Platform.OS !== "android" ? (
+
+ ) : null}
+
+ );
+}
+
+export function usePlayer() {
+ const value = useContext(PlayerContext);
+ if (!value) throw new Error("usePlayer must be used inside PlayerProvider.");
+ return value;
+}
+
+export function usePlayerPosition() {
+ return useContext(PlayerPositionContext);
+}
+
+export { playerAudioPresets };
diff --git a/apps/mobile/src/providers/session-provider.tsx b/apps/mobile/src/providers/session-provider.tsx
new file mode 100644
index 00000000..e19d123d
--- /dev/null
+++ b/apps/mobile/src/providers/session-provider.tsx
@@ -0,0 +1,382 @@
+import * as SecureStore from "expo-secure-store";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ getSetupStatus,
+ indexSetupLibrary,
+ isValid,
+ login as loginRequest,
+ register,
+ type SetupStatus,
+} from "@parson/music-sdk";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PropsWithChildren,
+} from "react";
+
+import { configureNativeRuntime, normalizeOrigin } from "@/lib/runtime";
+import { downloadedRecords, hydrateDownloads } from "@/lib/downloads";
+
+const SERVER_KEY = "parson.server-origin";
+const TOKEN_KEY = "parson.access-token";
+const INSTANCE_KEY = "parson.instance-id";
+const LIBRARY_KEY = "parson.library-name";
+
+export type SessionPhase =
+ | "loading"
+ | "discovering"
+ | "connecting"
+ | "login"
+ | "indexing"
+ | "setup"
+ | "offline"
+ | "ready";
+
+type Claims = NonNullable>["claims"]>;
+
+type SessionContextValue = {
+ claims: Claims | null;
+ error: string | null;
+ instanceId: string | null;
+ libraryName: string | null;
+ origin: string | null;
+ phase: SessionPhase;
+ setupStatus: SetupStatus | null;
+ connect: (origin: string, manifest?: DiscoveryManifest) => Promise;
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+ changeServer: () => Promise;
+ retry: () => Promise;
+ updateBitrate: (bitrate: number) => void;
+ setupAccount: (
+ username: string,
+ password: string,
+ setupCode?: string,
+ ) => Promise;
+ setupLibrary: (path: string) => Promise;
+};
+
+export type DiscoveryManifest = {
+ protocol: string;
+ protocolVersion: number;
+ instanceId: string;
+ name: string;
+ product: string;
+ serverVersion: string;
+};
+
+const SessionContext = createContext(null);
+
+async function readManifest(origin: string): Promise {
+ const response = await fetch(`${origin}/.well-known/parson`, {
+ headers: { Accept: "application/json" },
+ });
+ if (!response.ok)
+ throw new Error(`Library returned HTTP ${response.status}.`);
+ const manifest = (await response.json()) as DiscoveryManifest;
+ if (
+ manifest.protocol !== "parson" ||
+ manifest.protocolVersion !== 1 ||
+ manifest.product !== "parson-music" ||
+ !manifest.instanceId
+ ) {
+ throw new Error("This is not a compatible Parson library.");
+ }
+ return manifest;
+}
+
+export function SessionProvider({ children }: PropsWithChildren) {
+ const queryClient = useQueryClient();
+ const [phase, setPhase] = useState("loading");
+ const [origin, setOrigin] = useState(null);
+ const [claims, setClaims] = useState(null);
+ const [instanceId, setInstanceId] = useState(null);
+ const [libraryName, setLibraryName] = useState(null);
+ const [error, setError] = useState(null);
+ const [setupStatus, setSetupStatus] = useState(null);
+ const generation = useRef(0);
+
+ const clearAuthentication = useCallback(async () => {
+ configureNativeRuntime({ token: null });
+ setClaims(null);
+ await SecureStore.deleteItemAsync(TOKEN_KEY);
+ }, []);
+
+ useEffect(() => {
+ configureNativeRuntime({
+ unauthorized: () => {
+ void clearAuthentication();
+ setPhase("login");
+ },
+ tokenChanged: (next) => {
+ void SecureStore.setItemAsync(TOKEN_KEY, next);
+ },
+ });
+ }, [clearAuthentication]);
+
+ const resolveSetup = useCallback(
+ async (initialSetup: SetupStatus, expectedGeneration: number) => {
+ let setup = initialSetup;
+ while (setup.library_state === "indexing") {
+ if (expectedGeneration !== generation.current) return;
+ setPhase("indexing");
+ await new Promise((resolve) => setTimeout(resolve, 2500));
+ if (expectedGeneration !== generation.current) return;
+ setup = await getSetupStatus();
+ }
+ if (expectedGeneration !== generation.current) return;
+ if (setup.setup_required) {
+ setSetupStatus(setup);
+ setPhase("setup");
+ return;
+ }
+ const session = await isValid();
+ if (expectedGeneration !== generation.current) return;
+ if (session.status && session.claims) {
+ setSetupStatus(null);
+ setClaims(session.claims);
+ setPhase("ready");
+ } else {
+ await clearAuthentication();
+ setPhase("login");
+ }
+ },
+ [clearAuthentication],
+ );
+
+ const connect = useCallback(
+ async (value: string, suppliedManifest?: DiscoveryManifest) => {
+ const currentGeneration = ++generation.current;
+ setError(null);
+ setPhase("connecting");
+ try {
+ const nextOrigin = normalizeOrigin(value);
+ const manifest = suppliedManifest ?? (await readManifest(nextOrigin));
+ configureNativeRuntime({ origin: nextOrigin });
+ const setup = await getSetupStatus();
+ if (currentGeneration !== generation.current) return false;
+ setOrigin(nextOrigin);
+ setInstanceId(manifest.instanceId);
+ setLibraryName(manifest.name);
+ await Promise.all([
+ SecureStore.setItemAsync(SERVER_KEY, nextOrigin),
+ SecureStore.setItemAsync(INSTANCE_KEY, manifest.instanceId),
+ SecureStore.setItemAsync(LIBRARY_KEY, manifest.name),
+ ]);
+ await resolveSetup(setup, currentGeneration);
+ return true;
+ } catch (cause) {
+ if (currentGeneration !== generation.current) return false;
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Could not reach that library.",
+ );
+ await hydrateDownloads();
+ setPhase(downloadedRecords().length ? "offline" : "discovering");
+ return false;
+ }
+ },
+ [resolveSetup],
+ );
+
+ const retry = useCallback(async () => {
+ if (origin) await connect(origin);
+ else setPhase("discovering");
+ }, [connect, origin]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const [savedOrigin, savedToken, savedInstance, savedLibrary] =
+ await Promise.all([
+ SecureStore.getItemAsync(SERVER_KEY),
+ SecureStore.getItemAsync(TOKEN_KEY),
+ SecureStore.getItemAsync(INSTANCE_KEY),
+ SecureStore.getItemAsync(LIBRARY_KEY),
+ ]);
+ if (cancelled) return;
+ setOrigin(savedOrigin);
+ setInstanceId(savedInstance);
+ setLibraryName(savedLibrary);
+ configureNativeRuntime({ origin: savedOrigin, token: savedToken });
+ if (!savedOrigin) {
+ setPhase("discovering");
+ return;
+ }
+ await hydrateDownloads();
+ if (cancelled) return;
+ if (downloadedRecords().length) {
+ if (!(await connect(savedOrigin)) && !cancelled) setPhase("offline");
+ return;
+ }
+ for (let attempt = 0; attempt < 8 && !cancelled; attempt += 1) {
+ if (await connect(savedOrigin)) return;
+ if (attempt < 7)
+ await new Promise((resolve) => setTimeout(resolve, 750));
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [connect]);
+
+ const login = useCallback(async (username: string, password: string) => {
+ setError(null);
+ setPhase("connecting");
+ try {
+ const response = await loginRequest({
+ username: username.trim(),
+ password,
+ });
+ if (!response.status || !response.access_token) {
+ setError(response.message || "Sign in failed.");
+ setPhase("login");
+ return false;
+ }
+ configureNativeRuntime({ token: response.access_token });
+ await SecureStore.setItemAsync(TOKEN_KEY, response.access_token);
+ const session = await isValid();
+ if (!session.status || !session.claims) {
+ setError(session.message || "The session could not be verified.");
+ setPhase("login");
+ return false;
+ }
+ setClaims(session.claims);
+ setPhase("ready");
+ return true;
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Could not sign in.");
+ setPhase("login");
+ return false;
+ }
+ }, []);
+
+ const setupAccount = useCallback(
+ async (username: string, password: string, setupCode?: string) => {
+ setError(null);
+ setPhase("connecting");
+ const response = await register({
+ username: username.trim(),
+ password,
+ setup_code: setupCode?.trim() || undefined,
+ });
+ if (!response.status || !response.access_token) {
+ setError(
+ response.message || "Could not create the administrator account.",
+ );
+ setPhase("setup");
+ return false;
+ }
+ configureNativeRuntime({ token: response.access_token });
+ await SecureStore.setItemAsync(TOKEN_KEY, response.access_token);
+ const next = await getSetupStatus();
+ await resolveSetup(next, generation.current);
+ return true;
+ },
+ [resolveSetup],
+ );
+
+ const setupLibrary = useCallback(
+ async (path: string) => {
+ setError(null);
+ setPhase("indexing");
+ try {
+ await indexSetupLibrary(path.trim());
+ const next = await getSetupStatus();
+ await resolveSetup(next, generation.current);
+ return true;
+ } catch (cause) {
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Could not index that folder.",
+ );
+ setPhase("setup");
+ return false;
+ }
+ },
+ [resolveSetup],
+ );
+
+ const logout = useCallback(async () => {
+ generation.current += 1;
+ queryClient.clear();
+ await clearAuthentication();
+ setPhase("login");
+ }, [clearAuthentication, queryClient]);
+
+ const updateBitrate = useCallback((bitrate: number) => {
+ setClaims((value) => (value ? { ...value, bitrate } : value));
+ }, []);
+
+ const changeServer = useCallback(async () => {
+ generation.current += 1;
+ queryClient.clear();
+ await clearAuthentication();
+ await Promise.all([
+ SecureStore.deleteItemAsync(SERVER_KEY),
+ SecureStore.deleteItemAsync(INSTANCE_KEY),
+ SecureStore.deleteItemAsync(LIBRARY_KEY),
+ ]);
+ configureNativeRuntime({ origin: null });
+ setOrigin(null);
+ setInstanceId(null);
+ setLibraryName(null);
+ setError(null);
+ setPhase("discovering");
+ }, [clearAuthentication, queryClient]);
+
+ const value = useMemo(
+ () => ({
+ claims,
+ error,
+ instanceId,
+ libraryName,
+ origin,
+ phase,
+ setupStatus,
+ changeServer,
+ connect,
+ login,
+ logout,
+ retry,
+ updateBitrate,
+ setupAccount,
+ setupLibrary,
+ }),
+ [
+ changeServer,
+ claims,
+ connect,
+ error,
+ instanceId,
+ libraryName,
+ login,
+ logout,
+ origin,
+ phase,
+ retry,
+ updateBitrate,
+ setupAccount,
+ setupLibrary,
+ setupStatus,
+ ],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useSession() {
+ const context = useContext(SessionContext);
+ if (!context)
+ throw new Error("useSession must be used inside SessionProvider.");
+ return context;
+}
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
new file mode 100644
index 00000000..c70cb599
--- /dev/null
+++ b/apps/mobile/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "paths": {
+ "@/*": ["./src/*"],
+ "@/assets/*": ["./assets/*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+}
diff --git a/apps/site/.gitignore b/apps/site/.gitignore
new file mode 100644
index 00000000..9e509ea7
--- /dev/null
+++ b/apps/site/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.next/
+.open-next/
+out/
+build/
+coverage/
+.source/
+next-env.d.ts
+*.tsbuildinfo
+.env*.local
+.vercel/
diff --git a/apps/site/app/api/search/route.ts b/apps/site/app/api/search/route.ts
new file mode 100644
index 00000000..2ddbf35b
--- /dev/null
+++ b/apps/site/app/api/search/route.ts
@@ -0,0 +1,4 @@
+import { source } from "../../../lib/source";
+import { createFromSource } from "fumadocs-core/search/server";
+
+export const { GET } = createFromSource(source);
diff --git a/apps/site/app/docs/[[...slug]]/page.tsx b/apps/site/app/docs/[[...slug]]/page.tsx
new file mode 100644
index 00000000..94afcf51
--- /dev/null
+++ b/apps/site/app/docs/[[...slug]]/page.tsx
@@ -0,0 +1,46 @@
+import { getMDXComponents } from "../../../components/mdx";
+import { source } from "../../../lib/source";
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import {
+ DocsBody,
+ DocsDescription,
+ DocsPage,
+ DocsTitle,
+} from "fumadocs-ui/layouts/docs/page";
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug?: string[] }>;
+}) {
+ const { slug } = await params;
+ const page = source.getPage(slug);
+ if (!page) notFound();
+ const Content = page.data.body;
+
+ return (
+
+ {page.data.title}
+ {page.data.description}
+
+
+
+
+ );
+}
+
+export function generateStaticParams() {
+ return source.generateParams();
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug?: string[] }>;
+}): Promise {
+ const { slug } = await params;
+ const page = source.getPage(slug);
+ if (!page) notFound();
+ return { title: page.data.title, description: page.data.description };
+}
diff --git a/apps/site/app/docs/layout.tsx b/apps/site/app/docs/layout.tsx
new file mode 100644
index 00000000..2d3d3465
--- /dev/null
+++ b/apps/site/app/docs/layout.tsx
@@ -0,0 +1,20 @@
+import { source } from "../../lib/source";
+import { baseOptions } from "../../lib/layout.shared";
+import { AppChrome } from "../../components/app-chrome";
+import { DocsLayout } from "fumadocs-ui/layouts/docs";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
diff --git a/apps/site/app/favicon.ico b/apps/site/app/favicon.ico
new file mode 100644
index 00000000..a6efb21e
Binary files /dev/null and b/apps/site/app/favicon.ico differ
diff --git a/apps/site/app/global.css b/apps/site/app/global.css
new file mode 100644
index 00000000..eb03e100
--- /dev/null
+++ b/apps/site/app/global.css
@@ -0,0 +1,3225 @@
+@import "tailwindcss";
+@import "fumadocs-ui/css/neutral.css";
+@import "fumadocs-ui/css/preset.css";
+
+:root,
+.dark {
+ --fd-background: 0 0% 3%;
+ --fd-foreground: 0 0% 98%;
+ --fd-muted: 0 0% 12%;
+ --fd-muted-foreground: 0 0% 63%;
+ --fd-popover: 0 0% 6%;
+ --fd-popover-foreground: 0 0% 98%;
+ --fd-card: 0 0% 7%;
+ --fd-card-foreground: 0 0% 98%;
+ --fd-border: 0 0% 15%;
+ --fd-primary: 0 0% 96%;
+ --fd-primary-foreground: 0 0% 3%;
+ --fd-secondary: 0 0% 12%;
+ --fd-secondary-foreground: 0 0% 96%;
+ --fd-accent: 0 0% 12%;
+ --fd-accent-foreground: 0 0% 96%;
+ --fd-ring: 0 0% 74%;
+ --fd-radius: 0.5rem;
+}
+
+* {
+ scrollbar-color: hsl(0 0% 19%) transparent;
+}
+
+html {
+ background: #000;
+}
+
+body {
+ background: #000;
+ color: #fafafa;
+ letter-spacing: 0;
+}
+
+.parson-app-rail {
+ position: fixed;
+ z-index: 60;
+ inset: 0 auto 0 0;
+ display: flex;
+ width: 80px;
+ flex-direction: column;
+ align-items: center;
+ background: #000;
+ padding: 12px 0 16px;
+}
+
+.parson-app-logo {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ place-items: center;
+ border-radius: 12px;
+ background: #080808;
+ text-decoration: none;
+}
+
+.parson-app-logo img {
+ width: 58px;
+ height: 58px;
+ max-width: none;
+}
+
+.parson-rail-nav {
+ display: flex;
+ margin-top: 32px;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.parson-rail-link {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ border: 1px solid transparent;
+ border-radius: 14px;
+ color: #52525b;
+ transition: 140ms ease;
+ transition-property: color, border-color, background;
+}
+
+.parson-rail-link:hover {
+ background: rgb(255 255 255 / 0.04);
+ color: #d4d4d8;
+}
+
+.parson-rail-link[data-active="true"] {
+ border-color: rgb(255 255 255 / 0.06);
+ background: rgb(255 255 255 / 0.08);
+ color: #fff;
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.03);
+}
+
+.parson-rail-help {
+ margin-top: auto;
+}
+
+.parson-tooltip-anchor {
+ position: relative;
+}
+
+.parson-rail-tooltip {
+ position: absolute;
+ z-index: 90;
+ left: calc(100% + 12px);
+ top: 50%;
+ width: max-content;
+ max-width: 180px;
+ transform: translate(4px, -50%);
+ border: 1px solid rgb(255 255 255 / 0.1);
+ border-radius: 7px;
+ background: #18181b;
+ padding: 6px 9px;
+ color: #f4f4f5;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ opacity: 0;
+ pointer-events: none;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.4);
+ transition:
+ opacity 120ms ease,
+ transform 120ms ease;
+}
+
+.parson-tooltip-anchor:hover .parson-rail-tooltip,
+.parson-tooltip-anchor:focus-visible .parson-rail-tooltip {
+ transform: translate(0, -50%);
+ opacity: 1;
+}
+
+.parson-shell-sidebar {
+ position: fixed;
+ z-index: 50;
+ inset: 56px auto 0 80px;
+ width: 264px;
+ overflow-y: auto;
+ background: #000;
+ padding: 24px 12px 32px;
+}
+
+.parson-shell-sidebar-title {
+ margin: 0 8px 16px;
+ color: #71717a;
+ font-size: 11px;
+ font-weight: 650;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.parson-site-navigation {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.parson-site-group {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.parson-site-group p {
+ margin: 0 8px 5px;
+ color: #d4d4d8;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.parson-site-group a {
+ display: flex;
+ min-height: 34px;
+ align-items: center;
+ border-radius: 8px;
+ padding: 7px 9px;
+ color: #71717a;
+ font-size: 13px;
+ font-weight: 450;
+ line-height: 1.3;
+ text-decoration: none;
+ transition: 120ms ease;
+ transition-property: color, background;
+}
+
+.parson-site-group a:hover {
+ background: rgb(255 255 255 / 0.04);
+ color: #d4d4d8;
+}
+
+.parson-site-group a[data-active="true"] {
+ background: rgb(255 255 255 / 0.08);
+ color: #fff;
+}
+
+.parson-app-topbar {
+ position: fixed;
+ z-index: 55;
+ inset: 0 0 auto 344px;
+ display: flex;
+ height: 56px;
+ align-items: center;
+ justify-content: center;
+ background: #000;
+}
+
+.parson-global-search {
+ display: flex;
+ width: min(520px, calc(100vw - 240px));
+ height: 40px;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid #27272a;
+ border-radius: 999px;
+ background: #000;
+ padding: 0 16px;
+ color: #52525b;
+ font-size: 14px;
+ text-align: left;
+ transition: 140ms ease;
+ transition-property: border-color, color, background;
+}
+
+.parson-global-search:hover {
+ border-color: #52525b;
+ background: #000;
+ color: #a1a1aa;
+}
+
+.parson-global-search span {
+ flex: 1;
+}
+
+.parson-global-search kbd {
+ color: #3f3f46;
+ font: inherit;
+ font-size: 11px;
+}
+
+::selection {
+ background: rgb(255 255 255 / 0.2);
+}
+
+#nd-docs-layout {
+ --fd-layout-width: 94rem;
+ min-height: calc(100dvh - 56px);
+ margin-top: 56px;
+ margin-left: 344px;
+ border: 1px solid rgb(255 255 255 / 0.1);
+ border-right: 0;
+ border-bottom: 0;
+ border-radius: 18px 0 0;
+ background: #050505;
+}
+
+#nd-page {
+ max-width: 920px !important;
+ padding: 24px 32px 72px !important;
+}
+
+#nd-subnav,
+#nd-sidebar-mobile {
+ background: rgb(6 6 6 / 0.88);
+ border-color: rgb(255 255 255 / 0.08);
+ backdrop-filter: blur(18px);
+}
+
+.parson-doc-nav-title {
+ color: #71717a;
+ font-size: 12px;
+ font-weight: 640;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.parson-mobile-shell {
+ display: none;
+}
+
+.prose {
+ --tw-prose-body: #a1a1aa;
+ --tw-prose-headings: #fafafa;
+ --tw-prose-lead: #a1a1aa;
+ --tw-prose-links: #f4f4f5;
+ --tw-prose-bold: #f4f4f5;
+ --tw-prose-counters: #767676;
+ --tw-prose-bullets: #565656;
+ --tw-prose-hr: #202020;
+ --tw-prose-quotes: #e8e8e8;
+ --tw-prose-quote-borders: #2a2a2a;
+ --tw-prose-captions: #777;
+ --tw-prose-code: #eeeeee;
+ --tw-prose-th-borders: #292929;
+ --tw-prose-td-borders: #1d1d1d;
+ font-size: 0.9375rem;
+ line-height: 1.7;
+}
+
+.prose h1,
+.prose h2,
+.prose h3 {
+ letter-spacing: -0.025em;
+}
+
+.prose h2 {
+ margin-top: 2.8rem;
+ padding-top: 0.25rem;
+}
+
+.prose :where(code):not(:where(pre *)) {
+ border: 1px solid #242424;
+ border-radius: 0.4rem;
+ background: #121212;
+ padding: 0.12rem 0.34rem;
+ font-weight: 520;
+}
+
+.prose :where(a) {
+ text-decoration-color: #4c4c4c;
+ text-underline-offset: 0.2em;
+}
+
+.prose :where(table) {
+ overflow: hidden;
+ border: 1px solid #1e1e1e;
+ border-radius: 0.8rem;
+ background: #0c0c0c;
+}
+
+.prose :where(th) {
+ background: #101010;
+}
+
+.quick-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin: 12px 0 28px;
+}
+
+.quick-card {
+ display: block;
+ min-height: 104px;
+ border: 1px solid rgb(255 255 255 / 0.08);
+ border-radius: 8px;
+ background: #080808;
+ padding: 16px;
+ color: inherit !important;
+ text-decoration: none !important;
+ transition:
+ border-color 150ms ease,
+ background 150ms ease;
+}
+
+.quick-card:hover {
+ border-color: rgb(255 255 255 / 0.14);
+ background: #0c0c0c;
+}
+
+.quick-card strong {
+ display: block;
+ margin-bottom: 0.35rem;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.quick-card span {
+ color: #71717a;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+@media (max-width: 640px) {
+ .quick-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.showcase-capture {
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ background: #000;
+}
+
+.showcase-capture .demo-app {
+ height: 100vh;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.showcase-capture .demo-window-controls {
+ display: none;
+}
+
+.demo-app {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ min-height: 620px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 20px;
+ background: #000;
+ color: #fff;
+ box-shadow: 0 40px 120px rgba(0, 0, 0, 0.55);
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ text-align: left;
+}
+
+.demo-app button,
+.demo-app input {
+ font: inherit;
+}
+
+.demo-app button {
+ color: inherit;
+}
+
+.demo-app button:focus-visible,
+.demo-app input:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.42);
+ outline-offset: 2px;
+}
+
+.demo-sidebar {
+ position: absolute;
+ inset: 0 auto 0 0;
+ z-index: 4;
+ display: flex;
+ width: 80px;
+ align-items: center;
+ flex-direction: column;
+ background: #000;
+}
+
+.demo-brand-button,
+.demo-sidebar nav button,
+.demo-settings {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ border: 0;
+ border-radius: 14px;
+ background: transparent;
+ color: #52525b;
+ cursor: pointer;
+ transition: 150ms ease;
+}
+
+.demo-brand-button {
+ width: 52px;
+ height: 52px;
+ margin-top: 8px;
+}
+
+.demo-brand-mark {
+ display: grid;
+ width: 42px;
+ height: 42px;
+ place-items: center;
+ border-radius: 10px;
+ background: #111;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
+ color: #f4f4f5;
+ font-size: 23px;
+ font-weight: 750;
+}
+
+.demo-sidebar nav {
+ display: flex;
+ width: 100%;
+ margin-top: 24px;
+ align-items: center;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.demo-sidebar nav button:hover,
+.demo-settings:hover {
+ background: rgba(255, 255, 255, 0.04);
+ color: #d4d4d8;
+}
+
+.demo-sidebar nav button.active {
+ background: rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
+ color: #fff;
+}
+
+.demo-sidebar svg {
+ width: 20px;
+ height: 20px;
+ stroke-width: 1.8;
+}
+.demo-settings {
+ margin-top: auto;
+ margin-bottom: 16px;
+}
+
+.demo-titlebar {
+ position: absolute;
+ z-index: 3;
+ top: 0;
+ right: 0;
+ left: 80px;
+ display: flex;
+ height: 56px;
+ align-items: center;
+ justify-content: center;
+ background: #000;
+}
+
+.demo-titlebar form {
+ position: relative;
+ width: min(520px, 52%);
+}
+
+.demo-titlebar form > svg {
+ position: absolute;
+ top: 50%;
+ left: 16px;
+ width: 16px;
+ height: 16px;
+ transform: translateY(-50%);
+ color: #71717a;
+}
+
+.demo-titlebar input {
+ width: 100%;
+ height: 40px;
+ padding: 8px 42px 8px 44px;
+ border: 1px solid #27272a;
+ border-radius: 999px;
+ outline: 0;
+ background: #000;
+ color: #e4e4e7;
+ font-size: 13px;
+}
+
+.demo-titlebar input::placeholder {
+ color: #52525b;
+}
+
+.demo-titlebar form button {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ width: 28px;
+ height: 28px;
+ transform: translateY(-50%);
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: #71717a;
+ cursor: pointer;
+}
+
+.demo-window-controls {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: flex;
+ height: 56px;
+ align-items: stretch;
+}
+
+.demo-window-controls span {
+ display: grid;
+ width: 48px;
+ height: 56px;
+ place-items: center;
+ color: #52525b;
+}
+.demo-window-controls svg {
+ width: 16px;
+ height: 16px;
+}
+.demo-window-controls span:nth-child(2) svg {
+ width: 14px;
+ height: 14px;
+}
+
+.demo-window-controls.macos {
+ right: auto;
+ left: 18px;
+ height: 56px;
+ align-items: center;
+ gap: 8px;
+}
+
+.demo-window-controls.macos span {
+ display: block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.18);
+}
+
+.demo-window-controls.macos .macos-close {
+ background: #ff5f57;
+}
+.demo-window-controls.macos .macos-minimize {
+ background: #febc2e;
+}
+.demo-window-controls.macos .macos-fullscreen {
+ background: #28c840;
+}
+
+.demo-surface {
+ position: absolute;
+ inset: 56px 0 0 80px;
+ overflow: auto;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 18px 0 0;
+ background: #050505;
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+}
+
+.demo-route {
+ width: 100%;
+ max-width: 1064px;
+ min-height: 100%;
+ margin: 0 auto;
+ padding: 28px 28px 130px;
+}
+
+.demo-route > h3 {
+ margin: 0 0 38px;
+ color: #fff;
+ font-size: 30px;
+ font-weight: 650;
+ letter-spacing: -0.025em;
+}
+
+.demo-feed {
+ display: flex;
+ flex-direction: column;
+ gap: 42px;
+}
+.demo-media-row {
+ width: 100%;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.demo-row-heading {
+ display: flex;
+ margin-bottom: 14px;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.demo-row-heading h4,
+.demo-discography h4 {
+ margin: 0;
+ color: #fafafa;
+ font-size: 18px;
+ font-weight: 620;
+ letter-spacing: -0.015em;
+}
+
+.demo-row-heading > div {
+ display: flex;
+ gap: 8px;
+}
+.demo-row-heading button {
+ display: grid;
+ width: 32px;
+ height: 32px;
+ place-items: center;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 50%;
+ background: #111;
+ color: #a1a1aa;
+ cursor: pointer;
+}
+.demo-row-heading button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+}
+.demo-row-heading svg {
+ width: 16px;
+ height: 16px;
+}
+
+.demo-row-scroll {
+ display: grid;
+ grid-auto-columns: calc((100% - 60px) / 4);
+ grid-auto-flow: column;
+ gap: 20px;
+ overflow-x: auto;
+ scroll-behavior: smooth;
+ scrollbar-width: none;
+}
+.demo-row-scroll::-webkit-scrollbar {
+ display: none;
+}
+
+.demo-media-card {
+ min-width: 0;
+}
+.demo-artwork {
+ position: relative;
+ aspect-ratio: 1;
+ overflow: hidden;
+ border-radius: 8px;
+ background: #111;
+}
+.demo-cover-link {
+ display: block;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.demo-cover-link img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: 300ms ease;
+}
+.demo-media-card:hover .demo-cover-link img {
+ transform: scale(1.018);
+ filter: brightness(0.78);
+}
+
+.demo-card-play {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
+ color: #000 !important;
+ cursor: pointer;
+ opacity: 0;
+ transform: translateY(4px);
+ transition: 180ms ease;
+}
+.demo-media-card:hover .demo-card-play,
+.demo-card-play:focus-visible {
+ opacity: 1;
+ transform: none;
+}
+.demo-card-play svg {
+ width: 20px;
+ height: 20px;
+ margin-left: 2px;
+ fill: currentColor;
+}
+
+.demo-card-title,
+.demo-card-subtitle {
+ display: block;
+ min-height: 24px;
+ max-width: 100%;
+ overflow: hidden;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.demo-card-title {
+ margin-top: 11px;
+ color: #f4f4f5 !important;
+ font-size: 14px;
+ font-weight: 620;
+ line-height: 20px;
+ cursor: pointer;
+}
+.demo-card-title:hover {
+ text-decoration: underline;
+}
+.demo-card-subtitle {
+ color: #71717a !important;
+ font-size: 13px;
+ line-height: 20px;
+ cursor: pointer;
+}
+button.demo-card-subtitle:hover {
+ color: #d4d4d8 !important;
+}
+
+.demo-back {
+ display: flex;
+ margin: 0 0 28px;
+ align-items: center;
+ gap: 7px;
+ border: 0;
+ background: transparent;
+ color: #71717a !important;
+ font-size: 13px;
+ cursor: pointer;
+}
+.demo-back:hover {
+ color: #fff !important;
+}
+.demo-back svg {
+ width: 15px;
+ height: 15px;
+}
+
+.demo-artist-view {
+ max-width: 960px;
+ padding-top: 36px;
+}
+.demo-artist-view > h3 {
+ margin-bottom: 48px;
+ font-size: clamp(44px, 5vw, 64px);
+ font-weight: 850;
+ line-height: 1;
+}
+.demo-discography {
+ display: flex;
+ flex-direction: column;
+ gap: 42px;
+}
+.demo-discography h4 {
+ margin-bottom: 18px;
+ font-size: 20px;
+}
+.demo-artist-grid,
+.demo-search-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 20px;
+}
+
+.demo-eyebrow {
+ margin: 4px 0 8px;
+ color: #71717a;
+ font-size: 12px;
+ font-weight: 650;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.demo-search-view > h3 {
+ margin-bottom: 28px;
+}
+
+.demo-album-view {
+ max-width: 900px;
+ padding-top: 30px;
+}
+.demo-album-hero {
+ display: flex;
+ align-items: flex-end;
+ gap: 22px;
+}
+.demo-album-hero > img {
+ width: 190px;
+ height: 190px;
+ border-radius: 8px;
+ object-fit: cover;
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
+}
+.demo-album-hero p {
+ margin: 0 0 7px;
+ color: #a1a1aa;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.demo-album-hero h3 {
+ margin: 0 0 16px;
+ color: #fff;
+ font-size: clamp(40px, 5vw, 66px);
+ font-weight: 850;
+ letter-spacing: -0.045em;
+ line-height: 0.95;
+}
+.demo-album-hero button {
+ min-height: 24px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #f4f4f5;
+ font-size: 14px;
+ font-weight: 650;
+ cursor: pointer;
+}
+.demo-album-hero button:hover {
+ text-decoration: underline;
+}
+.demo-album-hero span {
+ color: #a1a1aa;
+ font-size: 14px;
+}
+.demo-album-actions {
+ display: flex;
+ height: 86px;
+ align-items: center;
+ gap: 22px;
+}
+.demo-album-actions button {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: #fff;
+ color: #000 !important;
+ cursor: pointer;
+}
+.demo-album-actions button svg {
+ width: 23px;
+ height: 23px;
+ margin-left: 2px;
+ fill: currentColor;
+}
+.demo-album-actions > svg {
+ color: #a1a1aa;
+}
+.demo-track-header,
+.demo-track-list li {
+ display: grid;
+ grid-template-columns: 40px 1fr 50px;
+ align-items: center;
+}
+.demo-track-header {
+ height: 38px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ color: #71717a;
+ font-size: 12px;
+}
+.demo-track-header svg {
+ width: 15px;
+ height: 15px;
+ justify-self: end;
+}
+.demo-track-list {
+ margin: 8px 0 0;
+ padding: 0;
+ list-style: none;
+}
+.demo-track-list li {
+ min-height: 58px;
+ padding: 6px 0;
+ border-radius: 6px;
+ color: #71717a;
+ font-size: 13px;
+}
+.demo-track-list li:hover {
+ background: rgba(255, 255, 255, 0.035);
+}
+.demo-track-list li > span:nth-child(2) {
+ display: flex;
+ flex-direction: column;
+}
+.demo-track-list b {
+ color: #e4e4e7;
+ font-size: 14px;
+ font-weight: 520;
+}
+.demo-track-list button {
+ width: fit-content;
+ min-height: 24px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #71717a;
+ font-size: 12px;
+ cursor: pointer;
+}
+.demo-track-list time {
+ justify-self: end;
+ padding-right: 4px;
+}
+
+.demo-player {
+ position: absolute;
+ z-index: 7;
+ bottom: 16px;
+ left: calc(50% + 40px);
+ display: grid;
+ width: min(900px, calc(100% - 112px));
+ min-height: 82px;
+ padding: 12px 16px 12px 20px;
+ grid-template-columns: minmax(180px, 1fr) minmax(260px, 1.4fr) minmax(
+ 140px,
+ 1fr
+ );
+ align-items: center;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 24px;
+ background: #080808;
+ box-shadow: 0 20px 70px rgba(0, 0, 0, 0.72);
+ transform: translateX(-50%);
+}
+
+.demo-player-track {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 10px;
+}
+.demo-player-cover {
+ display: block;
+ width: 48px;
+ height: 48px;
+ min-height: 0 !important;
+ flex: none;
+ overflow: hidden !important;
+ border-radius: 7px;
+}
+.demo-player-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.demo-player-track > span {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+}
+.demo-player-track button {
+ overflow: hidden;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.demo-player-track button {
+ min-height: 24px;
+ color: #71717a;
+ font-size: 12px;
+ cursor: pointer;
+}
+.demo-player-track .demo-player-title {
+ color: #f4f4f5;
+ font-size: 13px;
+ font-weight: 620;
+}
+.demo-player-track > svg {
+ width: 17px;
+ height: 17px;
+ margin-left: 4px;
+ color: #71717a;
+}
+
+.demo-player-center {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ gap: 8px;
+}
+.demo-player-center > div {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+.demo-player-center button {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: #a1a1aa;
+ cursor: pointer;
+}
+.demo-player-center button svg {
+ width: 17px;
+ height: 17px;
+}
+.demo-player-center .demo-main-play {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ background: #fff;
+ color: #000;
+}
+.demo-player-center .demo-main-play svg {
+ width: 18px;
+ height: 18px;
+ fill: currentColor;
+}
+.demo-player-center > span {
+ display: grid;
+ width: 100%;
+ grid-template-columns: 28px 1fr 28px;
+ align-items: center;
+ gap: 7px;
+}
+.demo-player-center small {
+ color: #52525b;
+ font-size: 9px;
+}
+.demo-player-center small:last-child {
+ text-align: right;
+}
+.demo-player-center i {
+ display: block;
+ height: 2px;
+ overflow: hidden;
+ border-radius: 2px;
+ background: #3f3f46;
+}
+.demo-player-center i em {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: #e4e4e7;
+}
+.demo-player-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 16px;
+ color: #71717a;
+}
+.demo-player-actions button {
+ display: grid;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: #71717a;
+ cursor: pointer;
+ transition: 150ms ease;
+}
+.demo-player-actions button:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+.demo-player-actions button.active {
+ background: #fff;
+ color: #000;
+}
+.demo-player-actions svg {
+ width: 17px;
+ height: 17px;
+}
+
+.demo-lyrics {
+ position: absolute;
+ z-index: 6;
+ inset: 56px 0 0 80px;
+ overflow: hidden;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 18px 0 0;
+ background: #000;
+}
+.demo-lyrics-backdrop {
+ position: absolute;
+ inset: -48px;
+ width: calc(100% + 96px);
+ height: calc(100% + 96px);
+ object-fit: cover;
+ opacity: 0.48;
+ filter: blur(38px) saturate(0.75);
+ transform: scale(1.08);
+}
+.demo-lyrics-shade {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.58);
+}
+.demo-lyrics-shade::after {
+ position: absolute;
+ inset: auto 0 0;
+ height: 110px;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent);
+ content: "";
+}
+.demo-lyrics-content {
+ position: relative;
+ z-index: 1;
+ width: min(720px, calc(100% - 48px));
+ height: 100%;
+ margin: 0 auto;
+ padding: calc(50vh - 84px) 0;
+ overflow-y: auto;
+ scrollbar-width: none;
+ text-align: center;
+}
+.demo-lyrics-content::-webkit-scrollbar {
+ display: none;
+}
+
+.demo-lyrics-content.capture-centered {
+ height: auto;
+ padding: 0 0 240px;
+ overflow: visible;
+ transform: translateY(calc(50vh - 96px - var(--active-line, 0) * 57px));
+}
+.demo-lyrics-content button {
+ display: block;
+ width: 100%;
+ margin: 0 0 18px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: clamp(20px, 2.25vw, 30px);
+ font-weight: 650;
+ line-height: 1.3;
+ text-align: center;
+ cursor: pointer;
+ transition: 250ms ease;
+}
+.demo-lyrics-content button:hover {
+ color: #fff;
+}
+.demo-lyrics-content button.past {
+ color: rgba(255, 255, 255, 0.25);
+}
+.demo-lyrics-content button.active {
+ color: #fff;
+ transform: scale(1.03);
+}
+
+.landing-platform-download {
+ overflow: hidden;
+}
+.landing-actions > .landing-platform-download,
+.landing-final .landing-platform-download {
+ width: 242px;
+}
+.landing-platform-download-content {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+.landing-platform-download svg {
+ width: 17px;
+ height: 17px;
+ flex: none;
+}
+.landing-platform-download svg[data-platform-icon="linux"] {
+ filter: grayscale(1) contrast(1.35);
+}
+.landing-library-demo-note {
+ min-height: 340px;
+ padding: 58px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 18px;
+ background: #050505;
+}
+.landing-library-demo-note > span {
+ display: inline-flex;
+ margin: 0 5px 8px 0;
+ padding: 7px 11px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
+ color: #a1a1aa;
+ font-size: 12px;
+}
+.landing-library-demo-note p {
+ max-width: 430px;
+ margin: 90px 0 0;
+ color: #71717a;
+ font-size: 15px;
+ line-height: 1.7;
+}
+.landing-library-demo-note strong {
+ color: #f4f4f5;
+ font-weight: 600;
+}
+
+@media (max-width: 900px) {
+ .demo-app {
+ min-height: 600px;
+ aspect-ratio: auto;
+ }
+ .demo-sidebar {
+ display: none;
+ }
+ .demo-titlebar {
+ left: 0;
+ }
+ .demo-window-controls {
+ display: none;
+ }
+ .demo-titlebar form {
+ width: min(520px, calc(100% - 32px));
+ }
+ .demo-surface {
+ inset: 56px 0 0 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+ .demo-route {
+ padding-inline: 20px;
+ }
+ .demo-row-scroll {
+ grid-auto-columns: calc((100% - 32px) / 3);
+ gap: 16px;
+ }
+ .demo-artist-grid,
+ .demo-search-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ }
+ .demo-player {
+ left: 50%;
+ width: calc(100% - 24px);
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ border-radius: 16px;
+ transform: translateX(-50%);
+ }
+ .demo-player-center {
+ align-items: flex-end;
+ }
+ .demo-player-center > span {
+ display: none;
+ }
+ .demo-player-actions {
+ gap: 2px;
+ }
+ .demo-player-actions button:not(.demo-lyrics-button) {
+ display: none;
+ }
+ .demo-lyrics {
+ inset: 56px 0 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+}
+
+@media (max-width: 640px) {
+ .demo-app {
+ min-height: 560px;
+ border-radius: 14px;
+ }
+ .demo-titlebar {
+ height: 52px;
+ }
+ .demo-titlebar input {
+ height: 36px;
+ }
+ .demo-surface {
+ top: 52px;
+ }
+ .demo-route {
+ padding: 22px 14px 112px;
+ }
+ .demo-route > h3 {
+ margin-bottom: 26px;
+ font-size: 24px;
+ }
+ .demo-feed {
+ gap: 32px;
+ }
+ .demo-row-scroll {
+ grid-auto-columns: calc((100% - 12px) / 2.15);
+ gap: 12px;
+ }
+ .demo-row-heading h4 {
+ font-size: 15px;
+ }
+ .demo-row-heading button {
+ width: 28px;
+ height: 28px;
+ }
+ .demo-card-play {
+ width: 36px;
+ height: 36px;
+ opacity: 1;
+ }
+ .demo-card-play svg {
+ width: 17px;
+ height: 17px;
+ }
+ .demo-card-title {
+ margin-top: 8px;
+ font-size: 12px;
+ }
+ .demo-card-subtitle {
+ font-size: 11px;
+ }
+ .demo-artist-view > h3 {
+ font-size: 40px;
+ }
+ .demo-artist-grid,
+ .demo-search-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px 12px;
+ }
+ .demo-album-hero {
+ align-items: flex-start;
+ gap: 14px;
+ }
+ .demo-album-hero > img {
+ width: 112px;
+ height: 112px;
+ }
+ .demo-album-hero h3 {
+ margin-bottom: 10px;
+ font-size: 28px;
+ }
+ .demo-album-hero p,
+ .demo-album-hero span {
+ font-size: 10px;
+ }
+ .demo-player {
+ bottom: 8px;
+ min-height: 70px;
+ padding: 8px 10px;
+ }
+ .demo-player-track img {
+ width: 42px;
+ height: 42px;
+ }
+ .demo-player-track > svg {
+ display: none;
+ }
+ .demo-player-center > div {
+ gap: 6px;
+ }
+ .demo-player-center > div > button:first-child,
+ .demo-player-center > div > button:last-child {
+ display: none;
+ }
+ .demo-lyrics {
+ top: 52px;
+ }
+ .demo-lyrics-content {
+ width: calc(100% - 28px);
+ padding-top: 28px;
+ }
+ .demo-lyrics-content button {
+ margin-bottom: 14px;
+ font-size: 19px;
+ }
+ .landing-library-demo-note {
+ min-height: 280px;
+ padding: 30px 24px;
+ }
+ .landing-library-demo-note p {
+ margin-top: 48px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .demo-app *,
+ .landing-page * {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
+
+@media (max-width: 767px) {
+ .parson-app-rail,
+ .parson-app-topbar,
+ .parson-shell-sidebar {
+ display: none;
+ }
+
+ .parson-mobile-shell {
+ position: sticky;
+ z-index: 70;
+ top: 0;
+ display: flex;
+ min-height: 56px;
+ align-items: flex-start;
+ justify-content: space-between;
+ border-bottom: 1px solid rgb(255 255 255 / 0.08);
+ background: rgb(0 0 0 / 0.94);
+ padding: 8px;
+ backdrop-filter: blur(18px);
+ }
+
+ .parson-mobile-shell details {
+ flex: 1;
+ }
+
+ .parson-mobile-shell summary,
+ .parson-mobile-shell > button {
+ display: flex;
+ min-height: 40px;
+ align-items: center;
+ border-radius: 8px;
+ color: #a1a1aa;
+ }
+
+ .parson-mobile-shell summary {
+ gap: 9px;
+ padding: 0 10px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 550;
+ list-style: none;
+ }
+
+ .parson-mobile-shell summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .parson-mobile-shell > button {
+ width: 40px;
+ justify-content: center;
+ }
+
+ .parson-mobile-shell details[open] {
+ padding-bottom: 12px;
+ }
+
+ .parson-mobile-shell .parson-site-navigation {
+ max-height: calc(100dvh - 72px);
+ overflow-y: auto;
+ padding: 12px 6px 8px 2px;
+ }
+
+ #nd-docs-layout {
+ min-height: 100dvh;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ }
+
+ #nd-page {
+ padding: 24px 16px 72px !important;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition: none !important;
+ }
+}
+
+.landing-page {
+ min-height: 100vh;
+ overflow: hidden;
+ background: #000;
+ color: #fafafa;
+}
+
+.landing-container {
+ width: min(1240px, calc(100% - 40px));
+ margin-inline: auto;
+}
+
+.landing-header {
+ position: fixed;
+ z-index: 100;
+ inset: 0 0 auto;
+ border-bottom: 1px solid rgb(255 255 255 / 0.07);
+ background: rgb(0 0 0 / 0.82);
+ backdrop-filter: blur(20px);
+}
+
+.landing-header-inner {
+ display: flex;
+ width: min(1380px, calc(100% - 40px));
+ height: 72px;
+ margin-inline: auto;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.landing-wordmark {
+ color: #fff;
+ font-size: 18px;
+ font-weight: 650;
+ letter-spacing: -0.04em;
+ text-decoration: none;
+}
+
+.landing-header nav,
+.landing-footer nav {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.landing-header nav a,
+.landing-footer a {
+ color: #a1a1aa;
+ font-size: 13px;
+ font-weight: 500;
+ text-decoration: none;
+ transition: color 140ms ease;
+}
+
+.landing-header nav a:hover,
+.landing-footer a:hover {
+ color: #fff;
+}
+
+.landing-header .landing-github-icon {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ place-items: center;
+ border-radius: 50%;
+}
+.landing-header .landing-github-icon:hover {
+ background: rgba(255, 255, 255, 0.06);
+}
+.landing-header .landing-github-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.landing-header .landing-nav-button {
+ height: 38px;
+ border-radius: 8px;
+ background: #fff;
+ padding: 10px 16px;
+ color: #09090b;
+ font-weight: 650;
+}
+
+.landing-sticky-download {
+ display: flex;
+ width: 0;
+ margin-right: -24px;
+ justify-content: flex-end;
+ overflow: hidden;
+ visibility: hidden;
+ transition:
+ width 280ms cubic-bezier(0.22, 1, 0.36, 1),
+ margin-right 280ms cubic-bezier(0.22, 1, 0.36, 1),
+ visibility 0s linear 280ms;
+ pointer-events: none;
+}
+
+.landing-sticky-download.visible {
+ width: 116px;
+ margin-right: 0;
+ visibility: visible;
+ transition-delay: 0s;
+ pointer-events: auto;
+}
+
+.landing-header .landing-sticky-download .landing-primary-button {
+ width: 116px;
+ min-height: 38px;
+ flex: none;
+ padding-inline: 14px;
+ color: #09090b;
+ font-size: 13px;
+ opacity: 1;
+ transform: translateX(10px);
+ transition:
+ background 140ms ease,
+ transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.landing-header .landing-sticky-download.visible .landing-primary-button {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+.landing-header
+ .landing-sticky-download.visible
+ .landing-platform-download-content,
+.landing-header
+ .landing-sticky-download.visible
+ .landing-platform-download
+ svg {
+ color: #09090b;
+ opacity: 1;
+}
+
+.landing-header
+ .landing-sticky-download.visible
+ .landing-platform-download
+ svg {
+ filter: grayscale(1) contrast(2);
+}
+
+.landing-header .landing-platform-download-content {
+ gap: 8px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .landing-sticky-download,
+ .landing-header .landing-sticky-download .landing-primary-button {
+ transition: none;
+ }
+}
+
+.landing-hero {
+ position: relative;
+ padding: 190px 0 96px;
+}
+
+.landing-glow {
+ position: absolute;
+ top: 24px;
+ left: 50%;
+ width: min(900px, 90vw);
+ height: 650px;
+ transform: translateX(-50%);
+ background: radial-gradient(circle, rgb(255 255 255 / 0.06), transparent 66%);
+ pointer-events: none;
+}
+
+.landing-hero-inner {
+ position: relative;
+ text-align: center;
+}
+
+.landing-hero h1,
+.landing-final h2 {
+ margin: 0;
+ font-size: clamp(3.8rem, 10vw, 8.6rem);
+ font-weight: 640;
+ letter-spacing: -0.075em;
+ line-height: 0.86;
+}
+
+.landing-hero h1 span,
+.landing-final h2 span {
+ color: rgb(255 255 255 / 0.37);
+}
+
+.landing-hero p {
+ margin: 38px 0 0;
+ color: #a1a1aa;
+ font-size: 17px;
+}
+
+.landing-actions {
+ display: flex;
+ margin-top: 30px;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+}
+
+.landing-primary-button {
+ display: inline-flex;
+ min-height: 48px;
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ border-radius: 8px;
+ background: #fff;
+ padding: 0 23px;
+ color: #09090b;
+ font-size: 14px;
+ font-weight: 650;
+ text-decoration: none;
+ transition: background 140ms ease;
+}
+
+.landing-primary-button:hover {
+ background: #d4d4d8;
+}
+
+.landing-text-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #d4d4d8;
+ font-size: 14px;
+ font-weight: 550;
+ text-underline-offset: 4px;
+}
+
+.landing-text-link svg {
+ width: 17px;
+ height: 17px;
+}
+
+.landing-section {
+ padding: 144px 0;
+}
+
+.landing-proof {
+ border-top: 1px solid rgb(255 255 255 / 0.07);
+ background: #050505;
+}
+
+.landing-copy-centered {
+ max-width: 760px;
+ margin: 0 auto 64px;
+ text-align: center;
+}
+
+.landing-copy h2,
+.landing-split-heading h2,
+.landing-how > .landing-container > h2,
+.landing-performance h2,
+.landing-trust h2 {
+ margin: 0;
+ color: #fafafa;
+ font-size: clamp(2.45rem, 5vw, 4.4rem);
+ font-weight: 620;
+ letter-spacing: -0.06em;
+ line-height: 0.98;
+}
+
+.landing-copy p,
+.landing-split-heading p {
+ margin: 24px auto 0;
+ color: #8b8b93;
+ font-size: 17px;
+ line-height: 1.75;
+}
+
+.landing-window {
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 0.11);
+ border-radius: 18px;
+ background: #080808;
+ box-shadow: 0 32px 90px rgb(0 0 0 / 0.45);
+}
+
+.landing-window-bar {
+ position: relative;
+ display: flex;
+ height: 48px;
+ align-items: center;
+ gap: 6px;
+ border-bottom: 1px solid rgb(255 255 255 / 0.07);
+ padding: 0 16px;
+}
+
+.landing-window-bar > span {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #3f3f46;
+}
+
+.landing-window-search {
+ position: absolute;
+ left: 50%;
+ display: flex;
+ width: min(360px, 40%);
+ height: 28px;
+ transform: translateX(-50%);
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border: 1px solid #27272a;
+ border-radius: 999px;
+ color: #52525b;
+ font-size: 10px;
+}
+
+.landing-product-shell {
+ display: grid;
+ min-height: 590px;
+ grid-template-columns: 64px 1fr;
+}
+
+.landing-product-rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 18px;
+ border-right: 1px solid rgb(255 255 255 / 0.06);
+ background: #030303;
+ padding: 16px 0;
+}
+
+.landing-product-rail b {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ margin-bottom: 12px;
+ place-items: center;
+ border-radius: 8px;
+ background: #111;
+ font-size: 18px;
+}
+
+.landing-product-rail span {
+ width: 20px;
+ height: 20px;
+ border: 1px solid #3f3f46;
+ border-radius: 6px;
+}
+
+.landing-product-rail span.active {
+ border-color: #a1a1aa;
+ background: #27272a;
+}
+
+.landing-product-page {
+ position: relative;
+ min-width: 0;
+ padding: 48px 6% 82px;
+}
+
+.landing-product-page h3 {
+ margin: 0 0 38px;
+ font-size: 26px;
+ font-weight: 650;
+ letter-spacing: -0.035em;
+}
+
+.landing-product-heading {
+ display: flex;
+ margin-bottom: 16px;
+ align-items: center;
+ justify-content: space-between;
+ color: #d4d4d8;
+ font-size: 13px;
+}
+
+.landing-product-heading span {
+ color: #52525b;
+ font-size: 10px;
+}
+
+.landing-album-row,
+.landing-release-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.landing-album {
+ min-width: 0;
+}
+
+.landing-cover {
+ position: relative;
+ display: grid;
+ aspect-ratio: 1;
+ overflow: hidden;
+ place-items: center;
+ border: 1px solid rgb(255 255 255 / 0.1);
+ border-radius: 8px;
+ isolation: isolate;
+}
+
+.landing-cover::before,
+.landing-cover::after {
+ position: absolute;
+ content: "";
+ z-index: -1;
+}
+
+.landing-cover::before {
+ inset: 12%;
+ border: 1px solid rgb(255 255 255 / 0.24);
+ transform: rotate(-8deg);
+}
+
+.landing-cover::after {
+ width: 70%;
+ height: 70%;
+ border-radius: 50%;
+ background: rgb(0 0 0 / 0.3);
+ filter: blur(18px);
+}
+
+.landing-cover span {
+ max-width: 80%;
+ color: rgb(255 255 255 / 0.87);
+ font-size: clamp(11px, 1.5vw, 19px);
+ font-weight: 750;
+ letter-spacing: -0.05em;
+ text-align: center;
+ text-transform: uppercase;
+}
+
+.cover-night {
+ background: linear-gradient(145deg, #151515, #4d2137 55%, #c06a40);
+}
+.cover-gold {
+ background: repeating-linear-gradient(
+ 45deg,
+ #c88c25 0 12px,
+ #f0c85d 12px 24px,
+ #5f2f18 24px 36px
+ );
+}
+.cover-mono {
+ background: radial-gradient(
+ circle at 70% 30%,
+ #f4f4f5 0 8%,
+ #27272a 9% 35%,
+ #09090b 36%
+ );
+}
+.cover-bronze {
+ background: linear-gradient(135deg, #d9b45b, #523416 55%, #09090b);
+}
+.cover-thriller {
+ background: linear-gradient(125deg, #eee 0 42%, #171717 43% 58%, #b11226 59%);
+}
+.cover-bad {
+ background: linear-gradient(145deg, #09090b, #27272a 50%, #a3a3a3);
+}
+.cover-dangerous {
+ background: radial-gradient(circle, #d1a842 0 18%, #621a2a 19% 45%, #111 46%);
+}
+.cover-wall {
+ background: linear-gradient(160deg, #eee 0 55%, #27272a 56%);
+}
+.cover-xscape {
+ background: radial-gradient(circle, #6b5130, #171717 58%, #020202);
+}
+.cover-live {
+ background: linear-gradient(135deg, #101010, #401414 48%, #d39f5f);
+}
+.cover-demo {
+ background: repeating-linear-gradient(90deg, #161616 0 8px, #3f3f46 8px 9px);
+}
+.cover-diana {
+ background: linear-gradient(140deg, #e6e1d7, #3f3f46 52%, #111);
+}
+
+.landing-album > b,
+.landing-album > small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.landing-album > b {
+ margin-top: 11px;
+ color: #d4d4d8;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.landing-album > small {
+ margin-top: 3px;
+ color: #52525b;
+ font-size: 10px;
+}
+
+.landing-player {
+ position: absolute;
+ right: 7%;
+ bottom: 18px;
+ left: 7%;
+ display: grid;
+ height: 58px;
+ grid-template-columns: 38px minmax(120px, 1fr) 36px 2fr;
+ align-items: center;
+ gap: 11px;
+ border: 1px solid rgb(255 255 255 / 0.08);
+ border-radius: 14px;
+ background: #101010;
+ padding: 0 13px;
+}
+
+.landing-mini-cover {
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+}
+
+.landing-player span b,
+.landing-player span small {
+ display: block;
+ font-size: 10px;
+}
+
+.landing-player span small {
+ margin-top: 3px;
+ color: #71717a;
+}
+.landing-player button {
+ display: grid;
+ width: 32px;
+ height: 32px;
+ place-items: center;
+ border-radius: 50%;
+ background: #fff;
+ color: #000;
+}
+.landing-player i {
+ height: 2px;
+ border-radius: 2px;
+ background: linear-gradient(90deg, #d4d4d8 0 38%, #3f3f46 38%);
+}
+
+.landing-library-proof {
+ background: #000;
+}
+
+.landing-split-heading {
+ display: grid;
+ margin-bottom: 64px;
+ grid-template-columns: 0.9fr 1.1fr;
+ align-items: end;
+ gap: 80px;
+}
+
+.landing-split-heading p {
+ max-width: 620px;
+ margin: 0;
+}
+
+.landing-artist-shell {
+ min-height: 710px;
+}
+
+.landing-product-page .landing-artist-title {
+ margin-bottom: 24px;
+ font-size: clamp(34px, 4vw, 58px);
+ font-weight: 780;
+}
+
+.landing-release-labels {
+ display: flex;
+ margin-bottom: 28px;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.landing-release-labels span {
+ border: 1px solid rgb(255 255 255 / 0.08);
+ border-radius: 999px;
+ background: #111;
+ padding: 6px 10px;
+ color: #71717a;
+ font-size: 9px;
+}
+
+.landing-release-grid {
+ row-gap: 28px;
+}
+
+.landing-how {
+ border-block: 1px solid rgb(255 255 255 / 0.07);
+ background: #060606;
+}
+
+.landing-steps {
+ display: grid;
+ margin-top: 68px;
+ grid-template-columns: repeat(3, 1fr);
+ border-block: 1px solid rgb(255 255 255 / 0.08);
+}
+
+.landing-steps article {
+ position: relative;
+ min-height: 260px;
+ padding: 38px 34px;
+}
+
+.landing-steps article + article {
+ border-left: 1px solid rgb(255 255 255 / 0.08);
+}
+
+.landing-steps article > svg {
+ color: #a1a1aa;
+}
+
+.landing-steps h3 {
+ margin: 54px 0 0;
+ font-size: 20px;
+ font-weight: 620;
+ letter-spacing: -0.03em;
+}
+
+.landing-steps p,
+.landing-performance p,
+.landing-availability small {
+ color: #71717a;
+ line-height: 1.65;
+}
+
+.landing-steps p {
+ margin: 10px 0 0;
+ font-size: 14px;
+}
+
+.landing-performance {
+ display: grid;
+ margin-top: 136px;
+ grid-template-columns: 1fr 1fr;
+ align-items: end;
+ gap: 80px;
+}
+
+.landing-kicker {
+ margin: 0 0 18px !important;
+ color: #71717a !important;
+ font-size: 11px !important;
+ font-weight: 650;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.landing-performance > div:last-child > p {
+ margin: 0 0 26px;
+ font-size: 16px;
+}
+
+.landing-performance a,
+.landing-trust a {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #f4f4f5;
+ font-size: 14px;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.landing-trust-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 120px;
+}
+
+.landing-section-icon {
+ margin-bottom: 32px;
+ color: #a1a1aa;
+}
+
+.landing-trust ul {
+ display: grid;
+ margin: 42px 0 32px;
+ padding: 0;
+ gap: 13px;
+ list-style: none;
+}
+
+.landing-trust li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #a1a1aa;
+ font-size: 14px;
+}
+
+.landing-trust li svg {
+ color: #d4d4d8;
+}
+
+.landing-availability {
+ border-left: 1px solid rgb(255 255 255 / 0.08);
+ padding-left: 72px;
+}
+
+.landing-availability > h2 {
+ margin: 0 0 22px;
+ font-size: 32px;
+ font-weight: 620;
+ letter-spacing: -0.045em;
+}
+
+.landing-availability > div {
+ display: flex;
+ min-height: 92px;
+ align-items: center;
+ gap: 20px;
+ border-bottom: 1px solid rgb(255 255 255 / 0.07);
+}
+
+.landing-availability > div > svg {
+ color: #71717a;
+}
+
+.landing-availability b,
+.landing-availability small {
+ display: block;
+}
+
+.landing-availability b {
+ font-size: 15px;
+ font-weight: 620;
+}
+
+.landing-availability small {
+ margin-top: 5px;
+ font-size: 12px;
+}
+
+.landing-later {
+ margin: 24px 0 0;
+ color: #3f3f46;
+ font-size: 12px;
+}
+
+.landing-final {
+ border-top: 1px solid rgb(255 255 255 / 0.07);
+ background: #050505;
+ padding: 170px 0;
+ text-align: center;
+}
+
+.landing-final h2 {
+ font-size: clamp(3.5rem, 8vw, 7.2rem);
+}
+
+.landing-final .landing-primary-button {
+ margin-top: 48px;
+}
+
+.landing-footer {
+ border-top: 1px solid rgb(255 255 255 / 0.07);
+ padding: 32px 0;
+}
+
+.landing-footer > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.landing-footer span {
+ color: #71717a;
+ font-size: 12px;
+}
+
+.landing-footer a {
+ color: #b4b4bc;
+}
+
+@media (max-width: 800px) {
+ .landing-container,
+ .landing-header-inner {
+ width: min(100% - 32px, 1240px);
+ }
+
+ .landing-header nav > a:not(.landing-nav-button) {
+ display: none;
+ }
+
+ .landing-sticky-download.visible,
+ .landing-header .landing-sticky-download .landing-primary-button {
+ width: 108px;
+ }
+
+ .landing-hero {
+ padding: 150px 0 118px;
+ }
+
+ .landing-hero h1 {
+ font-size: clamp(3.65rem, 17vw, 5.4rem);
+ }
+
+ .landing-hero p {
+ margin-top: 30px;
+ font-size: 15px;
+ }
+
+ .landing-actions {
+ flex-direction: column;
+ gap: 18px;
+ }
+
+ .landing-primary-button {
+ width: 100%;
+ }
+
+ .landing-section {
+ padding: 96px 0;
+ }
+
+ .landing-copy-centered {
+ margin-bottom: 42px;
+ text-align: left;
+ }
+
+ .landing-copy h2,
+ .landing-split-heading h2,
+ .landing-how > .landing-container > h2,
+ .landing-performance h2,
+ .landing-trust h2 {
+ font-size: 2.65rem;
+ }
+
+ .landing-copy p,
+ .landing-split-heading p {
+ font-size: 15px;
+ }
+
+ .landing-window {
+ border-radius: 12px;
+ }
+
+ .landing-window-bar {
+ height: 36px;
+ }
+
+ .landing-window-search {
+ display: none;
+ }
+
+ .landing-product-shell {
+ min-height: 400px;
+ grid-template-columns: 38px 1fr;
+ }
+
+ .landing-product-rail {
+ gap: 12px;
+ padding-top: 10px;
+ }
+
+ .landing-product-rail b {
+ width: 25px;
+ height: 25px;
+ margin-bottom: 8px;
+ font-size: 13px;
+ }
+
+ .landing-product-rail span {
+ width: 14px;
+ height: 14px;
+ border-radius: 4px;
+ }
+
+ .landing-product-page {
+ padding: 28px 18px 68px;
+ }
+
+ .landing-product-page h3 {
+ margin-bottom: 24px;
+ font-size: 20px;
+ }
+
+ .landing-album-row,
+ .landing-release-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ }
+
+ .landing-album-row .landing-album:nth-child(n + 3) {
+ display: none;
+ }
+
+ .landing-player {
+ right: 12px;
+ bottom: 12px;
+ left: 12px;
+ height: 48px;
+ grid-template-columns: 28px 1fr 30px;
+ }
+
+ .landing-mini-cover {
+ width: 28px;
+ height: 28px;
+ }
+
+ .landing-player i {
+ display: none;
+ }
+
+ .landing-split-heading,
+ .landing-performance,
+ .landing-trust-grid {
+ grid-template-columns: 1fr;
+ gap: 38px;
+ }
+
+ .landing-split-heading {
+ margin-bottom: 44px;
+ }
+
+ .landing-artist-shell {
+ min-height: 720px;
+ }
+
+ .landing-product-page .landing-artist-title {
+ font-size: 29px;
+ }
+
+ .landing-release-labels {
+ gap: 5px;
+ }
+
+ .landing-release-labels span {
+ padding: 5px 7px;
+ }
+
+ .landing-steps {
+ margin-top: 44px;
+ grid-template-columns: 1fr;
+ }
+
+ .landing-steps article {
+ min-height: 210px;
+ padding: 30px 6px;
+ }
+
+ .landing-steps article + article {
+ border-top: 1px solid rgb(255 255 255 / 0.08);
+ border-left: 0;
+ }
+
+ .landing-steps h3 {
+ margin-top: 42px;
+ }
+
+ .landing-performance {
+ margin-top: 92px;
+ }
+
+ .landing-availability {
+ border-top: 1px solid rgb(255 255 255 / 0.08);
+ border-left: 0;
+ padding-top: 48px;
+ padding-left: 0;
+ }
+
+ .landing-final {
+ padding: 118px 0;
+ }
+
+ .landing-footer > div,
+ .landing-footer nav {
+ align-items: flex-start;
+ }
+
+ .landing-footer > div {
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ .landing-footer nav {
+ flex-wrap: wrap;
+ gap: 12px 20px;
+ }
+}
+
+.demo-settings.active {
+ background: rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
+ color: #fff;
+}
+
+.demo-library-view,
+.demo-settings-view {
+ max-width: 960px;
+}
+
+.demo-library-artists > h4,
+.demo-library-albums > h4 {
+ margin: 0 0 16px;
+ color: #f4f4f5;
+ font-size: 18px;
+ font-weight: 620;
+}
+
+.demo-library-artists > div {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.demo-library-artists > div > button {
+ display: grid;
+ min-width: 0;
+ padding: 12px;
+ grid-template-columns: 52px 1fr auto;
+ align-items: center;
+ gap: 13px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ background: #090909;
+ text-align: left;
+ cursor: pointer;
+ transition: 150ms ease;
+}
+.demo-library-artists > div > button:hover {
+ background: #101010;
+ border-color: rgba(255, 255, 255, 0.13);
+}
+.demo-library-artists img {
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+.demo-library-artists span {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+}
+.demo-library-artists b {
+ overflow: hidden;
+ color: #f4f4f5;
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.demo-library-artists small {
+ margin-top: 3px;
+ color: #71717a;
+ font-size: 12px;
+}
+.demo-library-artists svg {
+ width: 16px;
+ height: 16px;
+ color: #52525b;
+}
+.demo-library-albums {
+ margin-top: 38px;
+}
+
+.demo-settings-view > p {
+ margin: -24px 0 34px;
+ color: #71717a;
+ font-size: 14px;
+}
+.demo-settings-list {
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ background: #080808;
+}
+.demo-settings-list > div {
+ display: flex;
+ min-height: 76px;
+ padding: 14px 18px;
+ align-items: center;
+ gap: 15px;
+}
+.demo-settings-list > div + div {
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
+}
+.demo-settings-list svg {
+ width: 19px;
+ height: 19px;
+ color: #71717a;
+}
+.demo-settings-list span {
+ display: flex;
+ flex-direction: column;
+}
+.demo-settings-list b {
+ color: #e4e4e7;
+ font-size: 14px;
+ font-weight: 560;
+}
+.demo-settings-list small {
+ margin-top: 4px;
+ color: #71717a;
+ font-size: 12px;
+}
+
+.demo-like {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ flex: none;
+ margin-left: 2px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: #71717a;
+ cursor: pointer;
+}
+.demo-like:hover {
+ background: rgba(255, 255, 255, 0.07);
+ color: #fff;
+}
+.demo-like.active {
+ color: #fb7185;
+}
+.demo-like.active:hover {
+ background: rgba(251, 113, 133, 0.1);
+ color: #fb7185;
+}
+.demo-like.active svg {
+ fill: currentColor;
+}
+.demo-like svg {
+ width: 17px;
+ height: 17px;
+}
+
+.demo-queue {
+ position: absolute;
+ z-index: 6;
+ right: 28px;
+ bottom: 112px;
+ width: 360px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 16px;
+ background: rgba(5, 5, 5, 0.96);
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(22px);
+}
+.demo-queue header {
+ display: flex;
+ padding: 18px 18px 12px;
+ align-items: center;
+ justify-content: space-between;
+}
+.demo-queue header p {
+ margin: 0;
+ color: #71717a;
+ font-size: 10px;
+ font-weight: 650;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.demo-queue header h4 {
+ margin: 4px 0 0;
+ color: #f4f4f5;
+ font-size: 15px;
+ font-weight: 620;
+}
+.demo-queue header button {
+ display: grid;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: #71717a;
+ cursor: pointer;
+}
+.demo-queue header button:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+.demo-queue header svg {
+ width: 16px;
+ height: 16px;
+}
+.demo-queue ol {
+ margin: 0;
+ padding: 0 8px 10px;
+ list-style: none;
+}
+.demo-queue li {
+ margin: 0;
+}
+.demo-queue li > button {
+ display: grid;
+ width: 100%;
+ min-height: 52px;
+ padding: 7px 10px;
+ grid-template-columns: 24px 1fr auto;
+ align-items: center;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: #71717a;
+ font-size: 11px;
+ text-align: left;
+ cursor: pointer;
+}
+.demo-queue-position {
+ display: grid;
+ place-items: center;
+}
+.demo-queue-position svg {
+ width: 13px;
+ height: 13px;
+}
+.demo-queue li.active .demo-queue-position,
+.demo-queue li.active small {
+ color: #f4f4f5;
+}
+.demo-queue li > button:hover,
+.demo-queue li.active > button {
+ background: rgba(255, 255, 255, 0.06);
+}
+.demo-queue li > button > div {
+ display: flex;
+ flex-direction: column;
+}
+.demo-queue li b {
+ color: #e4e4e7;
+ font-size: 12px;
+ font-weight: 540;
+}
+.demo-queue li small {
+ margin-top: 2px;
+ color: #71717a;
+ font-size: 10px;
+}
+.demo-queue time {
+ font-size: 10px;
+}
+
+@media (max-width: 900px) {
+ .demo-library-artists > div {
+ grid-template-columns: 1fr;
+ }
+ .demo-queue {
+ right: 12px;
+ bottom: 88px;
+ width: min(340px, calc(100% - 24px));
+ }
+ .demo-player-actions button.demo-lyrics-button,
+ .demo-player-actions button:first-child {
+ display: grid;
+ }
+}
+
+@media (max-width: 640px) {
+ .demo-library-artists > div > button {
+ grid-template-columns: 44px 1fr auto;
+ padding: 10px;
+ }
+ .demo-library-artists img {
+ width: 44px;
+ height: 44px;
+ }
+ .demo-settings-view > p {
+ margin-top: -16px;
+ }
+ .demo-settings-list > div {
+ min-height: 68px;
+ padding-inline: 14px;
+ }
+ .demo-queue {
+ bottom: 86px;
+ }
+}
+
+.landing-wide-container {
+ width: min(1380px, calc(100% - 40px));
+ margin-inline: auto;
+}
+
+.landing-hero h1,
+.landing-final h2 {
+ color: #fff;
+}
+
+.landing-proof,
+.landing-library-proof,
+.landing-final {
+ border: 0;
+ background: #000;
+}
+
+.landing-proof {
+ padding-top: 24px;
+}
+
+.landing-screenshots {
+ overflow: hidden;
+ padding: 150px 0 170px;
+ background:
+ radial-gradient(circle at 18% 52%, rgb(117 29 29 / 0.13), transparent 34%),
+ #000;
+}
+
+.landing-screenshot-copy {
+ display: grid;
+ max-width: 720px;
+ margin: 0 auto 56px;
+ justify-items: center;
+ text-align: center;
+}
+
+.landing-screenshot-copy > p {
+ margin: 0 0 18px;
+ color: #71717a;
+ font-size: 12px;
+ font-weight: 650;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.landing-screenshot-copy > h2 {
+ margin: 0;
+ color: #fafafa;
+ font-size: clamp(2.7rem, 5.2vw, 5rem);
+ font-weight: 620;
+ letter-spacing: -0.065em;
+ line-height: 0.97;
+}
+
+.landing-screenshot-copy > span {
+ max-width: 560px;
+ margin-top: 26px;
+ color: #8b8b93;
+ font-size: 16px;
+ line-height: 1.7;
+}
+
+.landing-screenshot-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.42fr) minmax(280px, 0.7fr);
+ align-items: stretch;
+ gap: 18px;
+}
+
+.landing-screenshot-grid > div {
+ display: grid;
+ gap: 18px;
+}
+
+.landing-screenshot-grid figure {
+ position: relative;
+ min-width: 0;
+ overflow: hidden;
+ margin: 0;
+ border: 1px solid rgb(255 255 255 / 0.1);
+ border-radius: 16px;
+ background: #050505;
+ box-shadow: 0 34px 90px rgb(0 0 0 / 0.5);
+}
+
+.landing-screenshot-grid img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.landing-screenshot-featured {
+ aspect-ratio: 16 / 9;
+}
+
+.landing-screenshot-grid > div figure {
+ aspect-ratio: 16 / 9;
+}
+
+@media (max-width: 900px) {
+ .landing-screenshots {
+ padding: 96px 0 112px;
+ }
+
+ .landing-screenshot-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .landing-screenshot-grid > div {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 600px) {
+ .landing-screenshot-copy {
+ margin-bottom: 36px;
+ }
+
+ .landing-screenshot-grid > div {
+ grid-template-columns: 1fr;
+ }
+
+ .landing-screenshot-grid figure {
+ border-radius: 10px;
+ }
+}
+
+.landing-copy-centered {
+ max-width: 650px;
+ margin-bottom: 32px;
+}
+
+.landing-copy p {
+ max-width: 480px;
+}
+
+.landing-library-layout {
+ display: grid;
+ grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr);
+ align-items: center;
+ gap: clamp(64px, 8vw, 130px);
+}
+
+.landing-library-copy {
+ max-width: 450px;
+}
+
+.landing-library-copy h2 {
+ margin: 0;
+ font-size: clamp(2.75rem, 5vw, 4.7rem);
+ font-weight: 620;
+ letter-spacing: -0.062em;
+ line-height: 0.98;
+}
+
+.landing-library-copy p {
+ margin: 26px 0 0;
+ color: #8b8b93;
+ font-size: 16px;
+ line-height: 1.75;
+}
+
+.landing-editorial-crop {
+ position: relative;
+ min-width: 0;
+ max-height: 610px;
+ overflow: hidden;
+ border-radius: 12px;
+ background: #070707;
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
+}
+
+.landing-editorial-crop::after {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 130px;
+ background: linear-gradient(transparent, #070707 88%);
+ content: "";
+ pointer-events: none;
+}
+
+.landing-editorial-content {
+ padding: 64px 7% 100px;
+}
+
+.landing-editorial-content .landing-artist-title {
+ margin: 0 0 24px;
+ font-size: clamp(2.4rem, 4vw, 4rem);
+ font-weight: 780;
+ letter-spacing: -0.055em;
+ line-height: 1;
+}
+
+.landing-editorial-content .landing-release-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.landing-how {
+ border: 0;
+ background: #050505;
+}
+
+.landing-mobile-break::before {
+ content: " ";
+}
+
+.landing-steps {
+ margin-top: 72px;
+ border: 0;
+ gap: clamp(36px, 6vw, 86px);
+}
+
+.landing-steps article {
+ display: grid;
+ min-height: 0;
+ grid-template-columns: auto 1fr;
+ align-items: start;
+ column-gap: 18px;
+ padding: 0;
+}
+
+.landing-steps article + article {
+ border: 0;
+}
+
+.landing-steps article > svg {
+ width: 19px;
+ height: 19px;
+ margin-top: 3px;
+ color: #71717a;
+}
+
+.landing-steps h3 {
+ margin: 0;
+ font-size: 20px;
+}
+
+.landing-steps p {
+ margin-top: 8px;
+ font-size: 15px;
+}
+
+.landing-trust-strip {
+ margin-top: 72px;
+ padding: 48px 0 0;
+ border-top: 1px solid rgb(255 255 255 / 0.08);
+ background: transparent;
+}
+
+.landing-trust-strip > div {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 26px;
+}
+
+.landing-trust-strip h2 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 620;
+ letter-spacing: -0.035em;
+}
+
+.landing-trust-strip p {
+ margin: 7px 0 0;
+ color: #71717a;
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.landing-trust-strip a {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #d4d4d8;
+ font-size: 13px;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.landing-final {
+ padding-block: 154px;
+}
+
+@media (max-width: 800px) {
+ .landing-actions > .landing-platform-download,
+ .landing-final .landing-platform-download {
+ width: 100%;
+ }
+
+ .landing-mobile-break {
+ display: block;
+ }
+
+ .landing-mobile-break::before {
+ content: none;
+ }
+
+ .landing-footer > div {
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .landing-footer nav {
+ margin-left: auto;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: nowrap;
+ gap: 12px;
+ }
+
+ .landing-wide-container {
+ width: min(100% - 24px, 1380px);
+ }
+
+ .landing-header nav > a {
+ display: inline-flex !important;
+ }
+
+ .landing-library-layout,
+ .landing-performance {
+ grid-template-columns: 1fr;
+ }
+
+ .landing-library-layout {
+ gap: 48px;
+ }
+
+ .landing-library-copy h2 {
+ font-size: 2.75rem;
+ }
+
+ .landing-editorial-crop {
+ max-height: 610px;
+ }
+
+ .landing-editorial-content {
+ padding: 36px 18px 90px;
+ }
+
+ .landing-editorial-content .landing-artist-title {
+ font-size: 2rem;
+ }
+
+ .landing-editorial-content .landing-release-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .landing-steps {
+ gap: 0;
+ }
+
+ .landing-steps article {
+ min-height: 0;
+ grid-template-columns: auto 1fr;
+ padding: 30px 0;
+ }
+
+ .landing-steps article + article {
+ border-top: 1px solid rgb(255 255 255 / 0.06);
+ }
+
+ .landing-trust-strip {
+ margin-top: 36px;
+ padding: 38px 0 0;
+ }
+
+ .landing-trust-strip > div {
+ grid-template-columns: 1fr;
+ align-items: start;
+ gap: 18px;
+ }
+
+ .landing-trust-strip a {
+ grid-column: 1;
+ }
+
+ .landing-final {
+ padding-block: 104px;
+ }
+}
diff --git a/apps/site/app/layout.tsx b/apps/site/app/layout.tsx
new file mode 100644
index 00000000..d9155522
--- /dev/null
+++ b/apps/site/app/layout.tsx
@@ -0,0 +1,48 @@
+import type { Metadata, Viewport } from "next";
+import { Inter } from "next/font/google";
+import { RootProvider } from "fumadocs-ui/provider/next";
+import "./global.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ metadataBase: new URL(
+ process.env.NEXT_PUBLIC_SITE_URL ?? "https://docs.parson.dev",
+ ),
+ title: { default: "Parson Docs", template: "%s · Parson Docs" },
+ description:
+ "Practical documentation for Parson, the local-first music server and player.",
+ applicationName: "Parson Docs",
+ icons: {
+ icon: [
+ { url: "/icons/icon.svg", type: "image/svg+xml" },
+ { url: "/icons/favicon-32x32.png", sizes: "32x32" },
+ { url: "/icons/favicon-16x16.png", sizes: "16x16" },
+ ],
+ apple: "/icons/apple-touch-icon.png",
+ },
+ openGraph: {
+ title: "Parson Docs",
+ description: "Practical documentation for your Parson music library.",
+ type: "website",
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#090909",
+ colorScheme: "dark",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/site/app/llms-full.txt/route.ts b/apps/site/app/llms-full.txt/route.ts
new file mode 100644
index 00000000..96ba6e67
--- /dev/null
+++ b/apps/site/app/llms-full.txt/route.ts
@@ -0,0 +1,11 @@
+import { getLLMText } from "../../lib/get-llm-text";
+import { source } from "../../lib/source";
+
+export const revalidate = false;
+
+export async function GET() {
+ const pages = await Promise.all(source.getPages().map(getLLMText));
+ return new Response(pages.join("\n\n---\n\n"), {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+}
diff --git a/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts b/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts
new file mode 100644
index 00000000..07079bb0
--- /dev/null
+++ b/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts
@@ -0,0 +1,21 @@
+import { getLLMText } from "../../../../lib/get-llm-text";
+import { source } from "../../../../lib/source";
+import { notFound } from "next/navigation";
+
+export const revalidate = false;
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ slug?: string[] }> },
+) {
+ const { slug } = await params;
+ const page = source.getPage(slug);
+ if (!page) notFound();
+ return new Response(await getLLMText(page), {
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
+ });
+}
+
+export function generateStaticParams() {
+ return source.generateParams();
+}
diff --git a/apps/site/app/llms.txt/route.ts b/apps/site/app/llms.txt/route.ts
new file mode 100644
index 00000000..8486aedf
--- /dev/null
+++ b/apps/site/app/llms.txt/route.ts
@@ -0,0 +1,10 @@
+import { source } from "../../lib/source";
+import { llms } from "fumadocs-core/source";
+
+export const revalidate = false;
+
+export function GET() {
+ return new Response(llms(source).index(), {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+}
diff --git a/apps/site/app/page.tsx b/apps/site/app/page.tsx
new file mode 100644
index 00000000..8b0ff485
--- /dev/null
+++ b/apps/site/app/page.tsx
@@ -0,0 +1,204 @@
+import { ArrowRight, FolderOpen, Monitor, Play } from "lucide-react";
+import type { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import InteractiveProductDemo from "../components/interactive-product-demo";
+import PlatformDownloadButton from "../components/platform-download-button";
+import StickyDownloadButton from "../components/sticky-download-button";
+
+export const metadata: Metadata = {
+ title: { absolute: "Parson - Your music. Instantly." },
+ description:
+ "Choose a folder and start listening. Parson is a local-first music server and player for Windows, Android, and the web.",
+};
+
+const trustItems = [
+ "Self-hosted",
+ "Local-first",
+ "Open source",
+ "No required cloud library upload",
+ "Lossless playback",
+];
+
+function GitHubIcon() {
+ return (
+
+
+
+ );
+}
+
+export default function Home() {
+ return (
+
+
+
+
+ Parson
+
+
+
+ Docs
+
+
+
+
+
+
+
+
+
+
+
+ Your music.
+
+ Instantly.
+
+
Choose a folder. Start listening.
+
+
+
+
+
+
+
+
+
+
Made for your collection
+
Every album gets room to breathe.
+
+ Browse your library, settle into a release, or follow the lyrics
+ without leaving your music behind.
+
+
+
+
+
+
+
+
+
+ Choose a folder.
+ Press play.
+
+
+
+
+
+
Install
+
One app. No server setup.
+
+
+
+
+
+
Choose your library
+
Point Parson at your music.
+
+
+
+
+
+
Listen
+
Windows, Android, or web.
+
+
+
+
+
+
+
Your music stays yours.
+
{trustItems.join(" - ")}
+
+
+ Privacy and Security
+
+
+
+
+
+
+
+
+
+ Choose a folder.
+
+ Start listening.
+
+
+
+
+
+
+
+
© ParsonLabs
+
+ Docs
+
+ GitHub
+
+ Privacy
+
+ Security
+
+
+
+
+
+ );
+}
diff --git a/apps/site/app/showcase/page.tsx b/apps/site/app/showcase/page.tsx
new file mode 100644
index 00000000..7d6e254f
--- /dev/null
+++ b/apps/site/app/showcase/page.tsx
@@ -0,0 +1,55 @@
+import type { Metadata } from "next";
+import InteractiveProductDemo from "../../components/interactive-product-demo";
+
+export const metadata: Metadata = {
+ title: "Parson product showcase",
+ robots: { follow: false, index: false },
+};
+
+function numberParameter(
+ value: string | string[] | undefined,
+ fallback: number,
+) {
+ const parsed = Number(Array.isArray(value) ? value[0] : value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export default async function ShowcasePage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ const parameters = await searchParams;
+ return (
+
+
+
+ );
+}
diff --git a/apps/site/capture-screenshots.cjs b/apps/site/capture-screenshots.cjs
new file mode 100644
index 00000000..4f4ed785
--- /dev/null
+++ b/apps/site/capture-screenshots.cjs
@@ -0,0 +1,186 @@
+const { spawn } = require("node:child_process");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { chromium } = require("playwright");
+
+const baseUrl = process.env.PARSON_SCREENSHOT_URL ?? "http://127.0.0.1:3010";
+const appDirectory = __dirname;
+const workspace = path.resolve(appDirectory, "../..");
+const outputDirectory = path.join(appDirectory, "public/screenshots");
+
+const shots = [
+ {
+ album: "afterlight",
+ file: "01-home.png",
+ time: 42,
+ title: "Home",
+ track: 0,
+ view: "home",
+ },
+ {
+ album: "night-swim",
+ file: "02-library.png",
+ time: 68,
+ title: "Library",
+ track: 1,
+ view: "library",
+ },
+ {
+ album: "evergreen",
+ file: "03-artist.png",
+ time: 91,
+ title: "Artist",
+ track: 2,
+ view: "artist",
+ },
+ {
+ album: "amber-hours",
+ file: "04-album.png",
+ time: 127,
+ title: "Album",
+ track: 3,
+ view: "album",
+ },
+ {
+ album: "still-form",
+ file: "05-search.png",
+ time: 153,
+ title: "Search",
+ track: 4,
+ query: "Mira",
+ view: "home",
+ },
+ {
+ album: "red-shift",
+ file: "06-now-playing.png",
+ playing: true,
+ time: 37,
+ title: "Now playing",
+ track: 5,
+ view: "home",
+ },
+ {
+ album: "golden-state",
+ file: "07-lyrics.png",
+ time: 114,
+ title: "Lyrics",
+ track: 7,
+ panel: "lyrics",
+ view: "home",
+ },
+];
+
+const requestedFile = process.env.PARSON_SCREENSHOT_FILE;
+const selectedShots = requestedFile
+ ? shots.filter((shot) => shot.file === requestedFile)
+ : shots;
+
+if (selectedShots.length === 0) {
+ throw new Error(`Unknown screenshot file: ${requestedFile}`);
+}
+
+async function serverIsReady() {
+ try {
+ const response = await fetch(`${baseUrl}/showcase`);
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+async function ensureServer() {
+ if (await serverIsReady()) return null;
+ const child = spawn(
+ "bun",
+ [
+ "--filter",
+ "parson-site",
+ "dev",
+ "--hostname",
+ "127.0.0.1",
+ "--port",
+ "3010",
+ ],
+ { cwd: workspace, stdio: "inherit" },
+ );
+ for (let attempt = 0; attempt < 60; attempt += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ if (await serverIsReady()) return child;
+ }
+ child.kill("SIGTERM");
+ throw new Error("Timed out waiting for the Parson showcase route");
+}
+
+async function settle(page) {
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ await Promise.all(
+ [...document.images].map(
+ (image) =>
+ image.complete ||
+ new Promise((resolve) => {
+ image.addEventListener("load", resolve, { once: true });
+ image.addEventListener("error", resolve, { once: true });
+ }),
+ ),
+ );
+ });
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ await page.waitForTimeout(700);
+}
+
+async function main() {
+ const server = await ensureServer();
+ const browser = await chromium.launch({ headless: true });
+ try {
+ await fs.mkdir(outputDirectory, { recursive: true });
+ for (const shot of selectedShots) {
+ const context = await browser.newContext({
+ colorScheme: "dark",
+ deviceScaleFactor: 2,
+ reducedMotion: "reduce",
+ viewport: { width: 1440, height: 810 },
+ });
+ const page = await context.newPage();
+ const parameters = new URLSearchParams({
+ album: shot.album,
+ time: String(shot.time),
+ track: String(shot.track),
+ view: shot.view,
+ });
+ if (shot.playing) parameters.set("playing", "true");
+ if (shot.panel) parameters.set("panel", shot.panel);
+ if (shot.query) parameters.set("query", shot.query);
+ await page.goto(`${baseUrl}/showcase?${parameters}`, {
+ waitUntil: "networkidle",
+ });
+ await page.addStyleTag({
+ content:
+ "nextjs-portal,.demo-window-controls{display:none!important}*,*::before,*::after{animation:none!important;caret-color:transparent!important;scroll-behavior:auto!important;transition:none!important}",
+ });
+ await settle(page);
+ // Warm the compositor to avoid stale blurred tiles.
+ await page.screenshot({ animations: "disabled" });
+ await page.waitForTimeout(350);
+ await page.screenshot({
+ animations: "disabled",
+ path: path.join(outputDirectory, shot.file),
+ });
+ process.stdout.write(`Captured ${shot.title}: ${shot.file}\n`);
+ await context.close();
+ }
+ } finally {
+ await browser.close();
+ server?.kill("SIGTERM");
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/apps/site/components/app-chrome.tsx b/apps/site/components/app-chrome.tsx
new file mode 100644
index 00000000..137c231e
--- /dev/null
+++ b/apps/site/components/app-chrome.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { useSearchContext } from "fumadocs-ui/contexts/search";
+import {
+ BookOpen,
+ CircleHelp,
+ Headphones,
+ Home,
+ Menu,
+ Search,
+ ShieldCheck,
+ SquareLibrary,
+} from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const railDestinations = [
+ { href: "/docs", label: "Home", icon: Home },
+ { href: "/docs/library", label: "Library", icon: BookOpen },
+ { href: "/docs/listening", label: "Listening", icon: Headphones },
+ {
+ href: "/docs/accounts-privacy-security",
+ label: "Keep it safe",
+ icon: ShieldCheck,
+ },
+ { href: "/docs/performance", label: "Reference", icon: SquareLibrary },
+];
+
+const docsSections = [
+ {
+ links: [
+ { href: "/docs", label: "Home" },
+ { href: "/docs/start-here", label: "Start here" },
+ { href: "/docs/download-and-install", label: "Download and install" },
+ ],
+ },
+ {
+ title: "Library",
+ links: [
+ { href: "/docs/library", label: "Library" },
+ { href: "/docs/supported-files", label: "Supported files" },
+ { href: "/docs/rescanning", label: "Rescanning and file changes" },
+ ],
+ },
+ {
+ title: "Use Parson",
+ links: [
+ { href: "/docs/listening", label: "Listening" },
+ { href: "/docs/lyrics", label: "Lyrics" },
+ {
+ href: "/docs/playlists-likes-history",
+ label: "Playlists, likes, and history",
+ },
+ { href: "/docs/connecting-devices", label: "Connecting devices" },
+ { href: "/docs/advanced-networking", label: "Advanced networking" },
+ ],
+ },
+ {
+ title: "Keep it safe",
+ links: [
+ {
+ href: "/docs/accounts-privacy-security",
+ label: "Accounts, privacy, and security",
+ },
+ { href: "/docs/data-safety", label: "Data safety" },
+ { href: "/docs/updates-and-migrations", label: "Updates and migrations" },
+ ],
+ },
+ {
+ title: "Reference",
+ links: [
+ { href: "/docs/troubleshooting", label: "Troubleshooting" },
+ { href: "/docs/performance", label: "Performance and indexing" },
+ { href: "/docs/diagnostics", label: "Diagnostics" },
+ { href: "/docs/development", label: "Development" },
+ ],
+ },
+];
+
+function isActive(pathname: string, href: string) {
+ return href === "/docs" ? pathname === href : pathname.startsWith(href);
+}
+
+function DocumentationLinks({ pathname }: { pathname: string }) {
+ return (
+
+ {docsSections.map((section, index) => (
+
+ {section.title ?
{section.title}
: null}
+ {section.links.map((link) => {
+ const active = isActive(pathname, link.href);
+ return (
+
+ {link.label}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+function RailTooltip({
+ id,
+ children,
+}: {
+ id: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function AppChrome() {
+ const pathname = usePathname();
+ const { setOpenSearch } = useSearchContext();
+
+ return (
+ <>
+
+
+
+ Parson docs
+
+
+ {railDestinations.map(({ href, label, icon: Icon }) => {
+ const active = isActive(pathname, href);
+ const tooltipId = `rail-tip-${label.toLowerCase()}`;
+ return (
+
+
+ {label}
+
+ );
+ })}
+
+
+
+
+ Troubleshooting
+
+
+
+
+
+
+
+ setOpenSearch(true)}
+ type="button"
+ >
+
+ Search Parson docs
+ Ctrl K
+
+
+
+
+
+
+
+ Parson Documentation
+
+
+
+ setOpenSearch(true)}
+ type="button"
+ >
+
+
+
+ >
+ );
+}
diff --git a/apps/site/components/interactive-product-demo.tsx b/apps/site/components/interactive-product-demo.tsx
new file mode 100644
index 00000000..65391555
--- /dev/null
+++ b/apps/site/components/interactive-product-demo.tsx
@@ -0,0 +1,1109 @@
+"use client";
+
+import {
+ ArrowLeft,
+ ArrowRight,
+ ChevronLeft,
+ ChevronRight,
+ Clock3,
+ FolderOpen,
+ Globe2,
+ HardDrive,
+ Heart,
+ Home,
+ Library,
+ ListMusic,
+ MicVocal,
+ Minus,
+ Pause,
+ Play,
+ Search,
+ Settings,
+ SkipBack,
+ SkipForward,
+ Square,
+ Volume2,
+ VolumeX,
+ X,
+} from "lucide-react";
+import type { CSSProperties } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+type Album = {
+ id: string;
+ title: string;
+ artist: string;
+ artwork: string;
+ type: string;
+ year: string;
+};
+
+const albums: Album[] = [
+ {
+ id: "afterlight",
+ title: "Afterlight",
+ artist: "Mira Sol",
+ artwork: "/album-art/afterlight.webp",
+ type: "Album",
+ year: "2026",
+ },
+ {
+ id: "night-swim",
+ title: "Night Swim",
+ artist: "Mira Sol",
+ artwork: "/album-art/night-swim.webp",
+ type: "Album",
+ year: "2025",
+ },
+ {
+ id: "amber-hours",
+ title: "Amber Hours",
+ artist: "North Arcade",
+ artwork: "/album-art/amber-hours.webp",
+ type: "Album",
+ year: "2026",
+ },
+ {
+ id: "evergreen",
+ title: "Evergreen",
+ artist: "Mira Sol",
+ artwork: "/album-art/evergreen.webp",
+ type: "Edition",
+ year: "2024",
+ },
+ {
+ id: "still-form",
+ title: "Still Form",
+ artist: "Mira Sol",
+ artwork: "/album-art/still-form.webp",
+ type: "Single",
+ year: "2026",
+ },
+ {
+ id: "red-shift",
+ title: "Red Shift",
+ artist: "Mira Sol",
+ artwork: "/album-art/red-shift.webp",
+ type: "Remix",
+ year: "2025",
+ },
+ {
+ id: "violet-river",
+ title: "Violet River",
+ artist: "Mira Sol",
+ artwork: "/album-art/violet-river.webp",
+ type: "Live",
+ year: "2023",
+ },
+ {
+ id: "distant-lines",
+ title: "Distant Lines",
+ artist: "North Arcade",
+ artwork: "/album-art/distant-lines.webp",
+ type: "Album",
+ year: "2024",
+ },
+ {
+ id: "golden-state",
+ title: "Golden State",
+ artist: "Mira Sol",
+ artwork: "/album-art/golden-state.webp",
+ type: "Bootleg",
+ year: "2022",
+ },
+];
+
+const tracks = [
+ ["First Light", "3:42", 222],
+ ["A Little Further", "4:08", 248],
+ ["Open Water", "3:19", 199],
+ ["Quiet Signals", "4:31", 271],
+ ["Into the Blue", "3:56", 236],
+ ["Northern Lines", "3:28", 208],
+ ["Weightless", "4:12", 252],
+ ["Signal Fires", "3:47", 227],
+ ["Homeward", "5:02", 302],
+] as const;
+
+const lyricTimes = [0, 18, 36, 54, 72, 90, 108, 126, 144, 162, 180, 200];
+
+function formatTime(value: number) {
+ const minutes = Math.floor(value / 60);
+ const seconds = Math.floor(value % 60)
+ .toString()
+ .padStart(2, "0");
+ return `${minutes}:${seconds}`;
+}
+
+type View =
+ | { name: "home" }
+ | { name: "library" }
+ | { name: "settings" }
+ | { name: "artist"; artist: string }
+ | { name: "album"; album: Album };
+
+function BrandMark() {
+ return (
+
+ P
+
+ );
+}
+
+type InteractiveProductDemoProps = {
+ initialAlbumId?: string;
+ initialPanel?: "lyrics" | "queue";
+ initialPlaying?: boolean;
+ initialQuery?: string;
+ initialTime?: number;
+ initialTrackIndex?: number;
+ initialView?: "album" | "artist" | "home" | "library" | "settings";
+};
+
+export default function InteractiveProductDemo({
+ initialAlbumId = "afterlight",
+ initialPanel,
+ initialPlaying = false,
+ initialQuery = "",
+ initialTime = 42,
+ initialTrackIndex = 0,
+ initialView = "home",
+}: InteractiveProductDemoProps = {}) {
+ const initialAlbum =
+ albums.find((album) => album.id === initialAlbumId) ?? albums[0];
+ const safeInitialTrackIndex = Math.max(
+ 0,
+ Math.min(initialTrackIndex, tracks.length - 1),
+ );
+ const initialViewState: View =
+ initialView === "album"
+ ? { name: "album", album: initialAlbum }
+ : initialView === "artist"
+ ? { name: "artist", artist: initialAlbum.artist }
+ : { name: initialView };
+ const [view, setView] = useState(initialViewState);
+ const [query, setQuery] = useState(initialQuery);
+ const [playing, setPlaying] = useState(initialPlaying);
+ const [currentAlbum, setCurrentAlbum] = useState(initialAlbum);
+ const [lyricsOpen, setLyricsOpen] = useState(initialPanel === "lyrics");
+ const [queueOpen, setQueueOpen] = useState(initialPanel === "queue");
+ const [currentTrackIndex, setCurrentTrackIndex] = useState(
+ safeInitialTrackIndex,
+ );
+ const [currentTime, setCurrentTime] = useState(initialTime);
+ const [liked, setLiked] = useState(false);
+ const [muted, setMuted] = useState(false);
+ const [showMacWindowControls, setShowMacWindowControls] = useState(false);
+ const trackDuration = tracks[currentTrackIndex][2];
+
+ useEffect(() => {
+ const source = `${navigator.userAgent} ${navigator.platform}`.toLowerCase();
+ setShowMacWindowControls(/macintosh|mac os|macintel/.test(source));
+ }, []);
+
+ const closePanels = () => {
+ setLyricsOpen(false);
+ setQueueOpen(false);
+ };
+ const goHome = () => {
+ closePanels();
+ setQuery("");
+ setView({ name: "home" });
+ };
+ const openLibrary = () => {
+ closePanels();
+ setQuery("");
+ setView({ name: "library" });
+ };
+ const openSettings = () => {
+ closePanels();
+ setQuery("");
+ setView({ name: "settings" });
+ };
+ const openArtist = (artist = "Mira Sol") => {
+ closePanels();
+ setQuery("");
+ setView({ name: "artist", artist });
+ };
+ const openAlbum = (album: Album) => {
+ closePanels();
+ setQuery("");
+ setView({ name: "album", album });
+ };
+ const playAlbum = (album: Album) => {
+ setCurrentAlbum(album);
+ setCurrentTrackIndex(0);
+ setCurrentTime(0);
+ setPlaying(true);
+ };
+ const moveTrack = (direction: number) => {
+ setCurrentTrackIndex(
+ (index) => (index + direction + tracks.length) % tracks.length,
+ );
+ setCurrentTime(0);
+ setPlaying(true);
+ };
+ const selectQueueTrack = (index: number) => {
+ setCurrentTrackIndex(index);
+ setCurrentTime(0);
+ setPlaying(true);
+ };
+
+ const activeLyric = useMemo(() => {
+ let active = 0;
+ lyricTimes.forEach((time, index) => {
+ if (time <= currentTime) active = index;
+ });
+ return active;
+ }, [currentTime]);
+
+ const visibleAlbums = useMemo(() => {
+ const value = query.trim().toLowerCase();
+ if (!value) return albums;
+ return albums.filter((album) =>
+ `${album.title} ${album.artist}`.toLowerCase().includes(value),
+ );
+ }, [query]);
+
+ useEffect(() => {
+ const closeLyrics = (event: KeyboardEvent) => {
+ if (event.key === "Escape") closePanels();
+ };
+ window.addEventListener("keydown", closeLyrics);
+ return () => window.removeEventListener("keydown", closeLyrics);
+ }, []);
+
+ useEffect(() => {
+ if (!playing) return;
+ const timer = window.setInterval(() => {
+ setCurrentTime((value) => (value >= trackDuration ? 0 : value + 1));
+ }, 1000);
+ return () => window.clearInterval(timer);
+ }, [playing, currentTrackIndex, currentAlbum.id]);
+
+ useEffect(() => {
+ if (!lyricsOpen && !queueOpen) return;
+ const dismissOutside = (event: PointerEvent) => {
+ const target = event.target as Element | null;
+ if (!target) return;
+ if (
+ lyricsOpen &&
+ !target.closest(".demo-lyrics") &&
+ !target.closest(".demo-lyrics-button")
+ )
+ setLyricsOpen(false);
+ if (
+ queueOpen &&
+ !target.closest(".demo-queue") &&
+ !target.closest('[aria-label="Queue"]')
+ )
+ setQueueOpen(false);
+ };
+ document.addEventListener("pointerdown", dismissOutside);
+ return () => document.removeEventListener("pointerdown", dismissOutside);
+ }, [lyricsOpen, queueOpen]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showMacWindowControls ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {query.trim() ? (
+
+ ) : view.name === "home" ? (
+
+ ) : view.name === "library" ? (
+
+ ) : view.name === "settings" ? (
+
+ ) : view.name === "artist" ? (
+
+ ) : (
+
openArtist(view.album.artist)}
+ onPlay={playAlbum}
+ />
+ )}
+
+
+ {lyricsOpen && (
+
setLyricsOpen(false)}
+ onLine={(index) => {
+ setCurrentTime(lyricTimes[index]);
+ setPlaying(true);
+ }}
+ />
+ )}
+ {queueOpen && !lyricsOpen && (
+ setQueueOpen(false)}
+ onSelect={selectQueueTrack}
+ />
+ )}
+
+ setPlaying((value) => !value)}
+ onPrevious={() => moveTrack(-1)}
+ onNext={() => moveTrack(1)}
+ onToggleLike={() => setLiked((value) => !value)}
+ onToggleLyrics={() => {
+ setQueueOpen(false);
+ setLyricsOpen((value) => !value);
+ }}
+ onToggleQueue={() => {
+ setLyricsOpen(false);
+ setQueueOpen((value) => !value);
+ }}
+ onToggleMute={() => setMuted((value) => !value)}
+ onArtist={openArtist}
+ onAlbum={openAlbum}
+ />
+
+ );
+}
+
+function HomeView({ onAlbum, onArtist, onPlay }: DemoActions) {
+ return (
+
+ );
+}
+
+function LibraryView({ onAlbum, onArtist, onPlay }: DemoActions) {
+ const artists = [
+ { name: "Mira Sol", detail: "7 releases", artwork: albums[0].artwork },
+ { name: "North Arcade", detail: "2 albums", artwork: albums[2].artwork },
+ ];
+
+ return (
+
+
Library
+
+ Artists
+
+ {artists.map((artist) => (
+
onArtist(artist.name)}
+ >
+
+
+ {artist.name}
+ {artist.detail}
+
+
+
+ ))}
+
+
+
+ Albums
+
+ {albums.slice(0, 8).map((album) => (
+
+ ))}
+
+
+
+ );
+}
+
+function SettingsView() {
+ const settings = [
+ {
+ title: "Music folders",
+ detail: "2 folders - available",
+ Icon: FolderOpen,
+ },
+ {
+ title: "Storage",
+ detail: "Local library and artwork cache",
+ Icon: HardDrive,
+ },
+ {
+ title: "Remote access",
+ detail: "Ready when this host is running",
+ Icon: Globe2,
+ },
+ ];
+
+ return (
+
+
Settings
+
Everything needed to run your library, in one place.
+
+ {settings.map(({ title, detail, Icon }) => (
+
+
+
+ {title}
+ {detail}
+
+
+ ))}
+
+
+ );
+}
+
+function SearchView({
+ albums: results,
+ onAlbum,
+ onArtist,
+ onPlay,
+}: DemoActions & { albums: Album[] }) {
+ return (
+
+
Search results
+
{results.length ? `${results.length} matches` : "Nothing found"}
+
+ {results.map((album) => (
+
+ ))}
+
+
+ );
+}
+
+function ArtistView({
+ artist,
+ onAlbum,
+ onBack,
+ onPlay,
+}: Pick & {
+ artist: string;
+ onBack: () => void;
+}) {
+ const artistAlbums = albums.filter((album) => album.artist === artist);
+ const groups = [
+ ["Albums", artistAlbums.filter((album) => album.type === "Album")],
+ ["Editions", artistAlbums.filter((album) => album.type === "Edition")],
+ [
+ "Singles and remixes",
+ artistAlbums.filter((album) => ["Single", "Remix"].includes(album.type)),
+ ],
+ [
+ "Live recordings and bootlegs",
+ artistAlbums.filter((album) => ["Live", "Bootleg"].includes(album.type)),
+ ],
+ ] as const;
+
+ return (
+
+
+ Home
+
+
{artist}
+
+ {groups.map(
+ ([title, items]) =>
+ items.length > 0 && (
+
+ {title}
+
+ {items.map((album) => (
+ {}}
+ onPlay={onPlay}
+ hideArtist
+ />
+ ))}
+
+
+ ),
+ )}
+
+
+ );
+}
+
+function AlbumView({
+ album,
+ onArtist,
+ onBack,
+ onPlay,
+}: Pick & {
+ album: Album;
+ onBack: () => void;
+}) {
+ return (
+
+
+ Artist
+
+
+
+
+
{album.type}
+
{album.title}
+
onArtist(album.artist)}>
+ {album.artist}
+
+
- {tracks.length} songs, 19 min
+
+
+
+
onPlay(album)}
+ aria-label={`Play ${album.title}`}
+ >
+
+
+
+
+ #
+ Title
+
+
+
+ {tracks.map(([title, duration], index) => (
+ onPlay(album)}>
+ {index + 1}
+
+ {title}
+ onArtist(album.artist)}>
+ {album.artist}
+
+
+ {duration}
+
+ ))}
+
+
+ );
+}
+
+type DemoActions = {
+ onAlbum: (album: Album) => void;
+ onArtist: (artist: string) => void;
+ onPlay: (album: Album) => void;
+};
+
+function MediaRow({
+ title,
+ items,
+ onAlbum,
+ onArtist,
+ onPlay,
+}: DemoActions & { title: string; items: Album[] }) {
+ const ref = useRef(null);
+ const move = (direction: number) =>
+ ref.current?.scrollBy({ left: direction * 520, behavior: "smooth" });
+ return (
+
+
+
{title}
+
+ move(-1)}
+ aria-label={`Scroll ${title} left`}
+ >
+
+
+ move(1)}
+ aria-label={`Scroll ${title} right`}
+ >
+
+
+
+
+
+ {items.map((album) => (
+
+ ))}
+
+
+ );
+}
+
+function MediaCard({
+ album,
+ onAlbum,
+ onArtist,
+ onPlay,
+ hideArtist = false,
+}: DemoActions & { album: Album; hideArtist?: boolean }) {
+ return (
+
+
+
onAlbum(album)}
+ aria-label={`Open ${album.title}`}
+ >
+
+
+
onPlay(album)}
+ aria-label={`Play ${album.title}`}
+ >
+
+
+
+ onAlbum(album)}
+ >
+ {album.title}
+
+ {hideArtist ? (
+ {album.type}
+ ) : (
+ onArtist(album.artist)}
+ >
+ {album.artist}
+
+ )}
+
+ );
+}
+
+const demoLyrics = [
+ "Streetlights fade into the morning",
+ "Windows glow and then they disappear",
+ "Every mile is opening before us",
+ "Nothing left to hold us here",
+ "We can follow where the quiet goes",
+ "Afterlight is all we need",
+ "Leave the shadows at the doorway",
+ "Let the open road begin",
+ "Every color finds the horizon",
+ "Every signal turns to gold",
+ "Keep the moment moving forward",
+ "There is more than we were told",
+];
+
+function DemoLyrics({
+ album,
+ activeLine,
+ centerActiveLine,
+ onClose,
+ onLine,
+}: {
+ album: Album;
+ activeLine: number;
+ centerActiveLine?: boolean;
+ onClose: () => void;
+ onLine: (index: number) => void;
+}) {
+ const contentRef = useRef(null);
+ const lineRefs = useRef>([]);
+
+ useEffect(() => {
+ const content = contentRef.current;
+ const line = lineRefs.current[activeLine];
+ if (!content || !line) return;
+ content.scrollTo({
+ top: line.offsetTop - content.clientHeight / 2 + line.clientHeight / 2,
+ behavior: "smooth",
+ });
+ }, [activeLine]);
+
+ return (
+ {
+ if (!(event.target as Element).closest(".demo-lyrics-content"))
+ onClose();
+ }}
+ >
+
+
+
+ {demoLyrics.map((line, index) => (
+ {
+ lineRefs.current[index] = node;
+ }}
+ type="button"
+ onClick={() => onLine(index)}
+ >
+ {line}
+
+ ))}
+
+
+ );
+}
+
+function DemoQueue({
+ album,
+ activeTrack,
+ onClose,
+ onSelect,
+}: {
+ album: Album;
+ activeTrack: number;
+ onClose: () => void;
+ onSelect: (index: number) => void;
+}) {
+ return (
+
+
+
+
Queue
+
{album.title}
+
+
+
+
+
+
+ {tracks.slice(activeTrack).map(([title, duration], offset) => {
+ const index = activeTrack + offset;
+ const isActive = index === activeTrack;
+ return (
+
+ onSelect(index)}
+ aria-label={`Play ${title}`}
+ >
+
+ {isActive ? : offset}
+
+
+ {title}
+
+ {isActive ? "Now playing · " : ""}
+ {album.artist}
+
+
+ {duration}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function DemoPlayer({
+ album,
+ playing,
+ lyricsOpen,
+ queueOpen,
+ liked,
+ muted,
+ currentTime,
+ trackDuration,
+ trackTitle,
+ onToggle,
+ onPrevious,
+ onNext,
+ onToggleLike,
+ onToggleLyrics,
+ onToggleQueue,
+ onToggleMute,
+ onArtist,
+ onAlbum,
+}: {
+ album: Album;
+ playing: boolean;
+ lyricsOpen: boolean;
+ queueOpen: boolean;
+ liked: boolean;
+ muted: boolean;
+ currentTime: number;
+ trackDuration: number;
+ trackTitle: string;
+ onToggle: () => void;
+ onPrevious: () => void;
+ onNext: () => void;
+ onToggleLike: () => void;
+ onToggleLyrics: () => void;
+ onToggleQueue: () => void;
+ onToggleMute: () => void;
+ onArtist: (artist: string) => void;
+ onAlbum: (album: Album) => void;
+}) {
+ const progress = Math.min(100, (currentTime / trackDuration) * 100);
+ return (
+
+ );
+}
diff --git a/apps/site/components/mdx.tsx b/apps/site/components/mdx.tsx
new file mode 100644
index 00000000..62ce6e51
--- /dev/null
+++ b/apps/site/components/mdx.tsx
@@ -0,0 +1,15 @@
+import defaultMdxComponents from "fumadocs-ui/mdx";
+import type { MDXComponents } from "mdx/types";
+
+export function getMDXComponents(components?: MDXComponents) {
+ return {
+ ...defaultMdxComponents,
+ ...components,
+ } satisfies MDXComponents;
+}
+
+export const useMDXComponents = getMDXComponents;
+
+declare global {
+ type MDXProvidedComponents = ReturnType;
+}
diff --git a/apps/site/components/platform-download-button.tsx b/apps/site/components/platform-download-button.tsx
new file mode 100644
index 00000000..86c362ae
--- /dev/null
+++ b/apps/site/components/platform-download-button.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { Download } from "lucide-react";
+import Link from "next/link";
+import type { ComponentType, SVGProps } from "react";
+import { useEffect, useState } from "react";
+
+type Platform =
+ "windows" | "android" | "ios" | "macos" | "linux" | "chromeos" | "unknown";
+
+type PlatformIcon = ComponentType>;
+
+function WindowsIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+function AndroidIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+function AppleIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+function LinuxIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+function ChromeIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+const platformDetails = {
+ windows: { label: "Windows", Icon: WindowsIcon },
+ android: { label: "Android", Icon: AndroidIcon },
+ ios: { label: "iPhone", Icon: AppleIcon },
+ macos: { label: "macOS", Icon: AppleIcon },
+ linux: { label: "Linux", Icon: LinuxIcon },
+ chromeos: { label: "ChromeOS", Icon: ChromeIcon },
+ unknown: { label: "your device", Icon: Download },
+} satisfies Record;
+
+function detectPlatform(): Platform {
+ const source = `${navigator.userAgent} ${navigator.platform}`.toLowerCase();
+ if (/android/.test(source)) return "android";
+ if (/iphone|ipad|ipod/.test(source)) return "ios";
+ if (/cros/.test(source)) return "chromeos";
+ if (/windows|win32|win64/.test(source)) return "windows";
+ if (/macintosh|mac os|macintel/.test(source)) return "macos";
+ if (/linux|x11/.test(source)) return "linux";
+ return "unknown";
+}
+
+export default function PlatformDownloadButton({
+ compact = false,
+}: {
+ compact?: boolean;
+}) {
+ const [platform, setPlatform] = useState(null);
+
+ useEffect(() => setPlatform(detectPlatform()), []);
+
+ const { label, Icon } = platform
+ ? platformDetails[platform]
+ : { label: "Parson", Icon: Download };
+ const buttonLabel = platform ? `Download for ${label}` : "Download Parson";
+
+ return (
+
+
+
+ {compact ? "Download" : buttonLabel}
+
+
+ );
+}
diff --git a/apps/site/components/sticky-download-button.tsx b/apps/site/components/sticky-download-button.tsx
new file mode 100644
index 00000000..3fc0464a
--- /dev/null
+++ b/apps/site/components/sticky-download-button.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import PlatformDownloadButton from "./platform-download-button";
+
+export default function StickyDownloadButton() {
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const heroDownload = document.getElementById("hero-download");
+ if (!heroDownload) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => setVisible(!entry.isIntersecting),
+ { threshold: 0.15 },
+ );
+
+ observer.observe(heroDownload);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/site/content/docs/accounts-privacy-security.mdx b/apps/site/content/docs/accounts-privacy-security.mdx
new file mode 100644
index 00000000..3dab5a3c
--- /dev/null
+++ b/apps/site/content/docs/accounts-privacy-security.mdx
@@ -0,0 +1,42 @@
+---
+title: Accounts, privacy, and security
+description: Permissions, passwords, sessions, external requests, and Parson's security boundary.
+---
+
+## Accounts and permissions
+
+The first account is an **administrator**. Administrators can create additional user or administrator accounts from Settings. Normal users can listen and manage their own playlists, likes, history, profile, and playback preferences; administrative server and account operations remain restricted.
+
+## Passwords and reset
+
+Passwords are salted and hashed with Argon2. Parson never stores the original password. A signed-in user can change their password by providing the current one; doing so revokes existing sessions.
+
+There is no email-based or built-in forgotten-password reset in Parson 1.0.x because there is no Parson cloud identity. An administrator should create a replacement account, or an operator can recover the database using documented administrative tooling when available. Do not edit password hashes by hand.
+
+## Sessions and devices
+
+Access and refresh tokens are signed with the private key in `Secrets\session.key`. Logout and password changes increment the account's token generation, invalidating older credentials. Clients should store credentials in platform-protected storage; Android app data is excluded from cloud/device-transfer backup.
+
+The temporary first-run setup code is 12 uppercase hexadecimal characters derived from the private session key. It is printed only while remote first-account setup is needed. Keep logs private.
+
+## Privacy
+
+### What stays local
+
+Music files, file paths, tags, artwork, accounts, passwords hashes, sessions, playlists, likes, history, recommendations, queues, search indexes, and diagnostics stay on the Parson host or on connected devices. Offline downloads stay on the Android device.
+
+### External requests Parson may make
+
+- Opening uncached lyrics sends track title, artist, album, and duration to `lrclib.net`.
+- Choosing **Check and install updates** contacts the official GitHub release endpoint, unless an operator configured a custom update manifest.
+- Google Cast uses Google's Cast infrastructure when you choose a Cast target.
+
+Parson 1.0.x has **no usage analytics, telemetry upload, or automatic crash-reporting service**. The term “playback telemetry” in source code means local listening events used for your history and recommendations; those events are not sent to ParsonLabs.
+
+### What ParsonLabs can see
+
+ParsonLabs cannot browse your library, accounts, history, or logs through a built-in service. It can see ordinary download traffic to infrastructure it operates and information you deliberately include in a support request or vulnerability report. LRCLIB, GitHub, your VPN/proxy provider, and Cast infrastructure have their own network visibility.
+
+## Security updates and vulnerabilities
+
+Security fixes are provided for the latest Parson 1.0.x release. Report vulnerabilities privately through the repository's **GitHub Security Advisories**; do not open a public exploit report. Include the version, reproduction steps, impact, and any proposed mitigation. The project aims to acknowledge reports within seven days.
diff --git a/apps/site/content/docs/advanced-networking.mdx b/apps/site/content/docs/advanced-networking.mdx
new file mode 100644
index 00000000..85f317f9
--- /dev/null
+++ b/apps/site/content/docs/advanced-networking.mdx
@@ -0,0 +1,43 @@
+---
+title: Advanced networking
+description: Hostnames, HTTPS, reverse proxies, firewalls, VPNs, and manual discovery.
+---
+
+Normal trusted-LAN use needs none of this page. Parson listens on port `1993` by default and advertises itself locally.
+
+## Custom hostname
+
+Point a DNS name at the Windows host, then connect clients using the complete origin. Local DNS, a router hostname, or a hosts-file entry can be enough on a private network. For an internet-visible hostname, use HTTPS.
+
+## Reverse proxy and TLS
+
+Terminate TLS at a reverse proxy such as Caddy, nginx, or Traefik and proxy the complete origin to Parson. Set:
+
+```text
+PARSON_PUBLIC_URL=https://music.example.com
+```
+
+The value must be a bare origin with no path. It enables secure cookies and allows that browser origin. Proxy WebSocket upgrades as well as normal HTTP and range requests. Do not expose Parson directly over plain HTTP to the internet.
+
+## Firewall rules
+
+Allow inbound TCP to the configured Parson port only from networks that should connect. Local discovery also needs multicast DNS on the LAN. Do not expose the data directory, database files, logs, or session key as shared files.
+
+## Tailscale or another VPN
+
+A private VPN is often the lowest-friction remote option. Install it on the host and client, connect using the host's VPN DNS name or address, and keep the Windows host running. Discovery may not cross the VPN, so use a manual endpoint.
+
+## Manual endpoint
+
+Use the complete origin, including scheme and a non-default port when applicable:
+
+```text
+http://192.168.1.20:1993
+https://music.example.com
+```
+
+Parson rejects embedded usernames/passwords and ignores URL paths. Android emulators can reach a development host at `http://10.0.2.2:1993`.
+
+## Custom discovery behavior
+
+`PARSON_LIBRARY_NAME` changes the nearby friendly name. `PARSON_PORT` changes the listener; `PARSON_BIND_ADDRESS` controls the bind address. A loopback-only bind disables LAN discovery. The native service type is `_parson._tcp.local.` and the discovery identity is persisted under `Discovery\instance-id`.
diff --git a/apps/site/content/docs/connecting-devices.mdx b/apps/site/content/docs/connecting-devices.mdx
new file mode 100644
index 00000000..facf90ff
--- /dev/null
+++ b/apps/site/content/docs/connecting-devices.mdx
@@ -0,0 +1,41 @@
+---
+title: Connecting devices
+description: Connect Android nearby, recover when discovery fails, and understand local, remote, and offline use.
+---
+
+## Connect Android
+
+1. Start Parson on Windows.
+2. Connect Windows and Android to the same trusted network.
+3. Open Parson on Android and select your library under **Nearby**.
+4. Sign in with a Parson account.
+
+Android discovers `_parson._tcp` services for a short window and validates each server through `/.well-known/parson`. Android 17 and newer may ask for Nearby devices access. Discovery stops after connection.
+
+## Pairing and approval
+
+Parson 1.0.x uses account sign-in as device approval; it does not have a second QR pairing ceremony for normal devices. The discovery manifest says pairing is required because a device must authenticate before library access.
+
+The 12-character setup code is only for creating the **first** account from a non-local device. It is not a reusable device credential.
+
+## Manual fallback
+
+If the library does not appear, choose **Enter address** and enter the full origin shown by Parson. On a trusted local network it normally looks like `http://computer-name:1993`. Remote deployments should use an HTTPS hostname. A QR fallback is not currently built in.
+
+## Revoke a device
+
+Sign out to revoke the account's current token generation. Changing the account password also invalidates existing sessions. Parson 1.0.x does not yet provide a named per-device session list; revocation applies to that account's sessions rather than one labeled handset.
+
+## Local connection behavior
+
+The Windows host serves the web UI, API, artwork, and audio. It must remain running and awake while another device streams. If Windows sleeps, shuts down, changes network, or loses access to the music folder, playback stops.
+
+When switching Wi-Fi networks, nearby discovery runs again. A manually saved local address may change if the router assigns a different address; select the discovered library again or give the host a stable local name/address in your router.
+
+## Remote access
+
+Remote access is possible through an HTTPS reverse proxy, Tailscale, or another trusted VPN. It is not a ParsonLabs relay: your device connects to your own host. Keep the Windows host running and see [Advanced networking](/docs/advanced-networking).
+
+## Offline downloads
+
+Android can download individual songs and complete albums. Downloaded items play from the device without the Windows host or network. Removing a download removes only the device copy, not the server's source file.
diff --git a/apps/site/content/docs/data-safety.mdx b/apps/site/content/docs/data-safety.mdx
new file mode 100644
index 00000000..523aa651
--- /dev/null
+++ b/apps/site/content/docs/data-safety.mdx
@@ -0,0 +1,41 @@
+---
+title: Data safety
+description: Back up Parson, restore it, and move a library to another computer.
+---
+
+## What to back up
+
+Stop Parson before copying `%LOCALAPPDATA%\Parson`. Back up the entire folder when possible.
+
+| Data | Default location |
+| -------------------------------------------------------------- | ---------------------------------------------------- |
+| Accounts and core library registrations | `Database\parson-core.db` |
+| Catalog, playlists, likes, history, queues, metadata overrides | `Database\parson-music.db` |
+| Verified database recovery snapshots | Beside `parson-music.db` |
+| Artwork and catalog cache | `Cache` and artwork folders under the data directory |
+| Lyrics cache | `Cache\Lyrics\lyrics.db` |
+| Session signing key | `Secrets\session.key` |
+| Library roots and settings | Inside the data directory and databases |
+
+Your audio files are separate. Back them up with your normal music-library backup.
+
+## Restore
+
+1. Install the same or a newer compatible Parson version.
+2. Make sure Parson is stopped.
+3. Restore the saved data folder to `%LOCALAPPDATA%\Parson`, or set `PARSON_DATA_DIR` to the restored folder.
+4. Start Parson and verify that accounts and the library load.
+
+Keep `session.key` private. Restoring it preserves the signing identity; omitting it forces new credentials and can invalidate devices.
+
+## Move to a new computer
+
+1. **Install Parson.** Use the current installer on the new machine.
+2. **Restore Parson data.** Stop Parson and copy the backed-up data folder into place.
+3. **Point it at the music folder.** If the drive letter or network path changed, update the registered root in Settings.
+4. **Verify the library.** Refresh and test several albums, artwork, playlists, likes, history, and lyrics.
+5. **Reconnect devices if necessary.** Saved local addresses may point to the old machine. Discover the new host or enter its new address.
+
+## Recovery snapshots
+
+After a successful catalog-changing import, Parson creates a verified SQLite snapshot in the background and normally keeps the newest three. If startup reports a failed database integrity check, stop Parson, preserve the failed database, and restore the newest healthy `parson-music.db.snapshot-*.db` file in its place.
diff --git a/apps/site/content/docs/development.mdx b/apps/site/content/docs/development.mdx
new file mode 100644
index 00000000..8530f205
--- /dev/null
+++ b/apps/site/content/docs/development.mdx
@@ -0,0 +1,61 @@
+---
+title: Development
+description: Build, test, contribute, release, and integrate with the open-source Parson codebase.
+---
+
+Parson is open source under **GPL-3.0-only**. Source, issues, releases, and security reporting live in the [Parson repository](https://github.com/ParsonLabs/Parson).
+
+## Build from source
+
+Install Bun 1.3.14, Rust stable, FFmpeg, and platform tooling. From the repository root:
+
+```powershell
+bun install --frozen-lockfile
+cargo run -p parson-music
+```
+
+In another terminal:
+
+```powershell
+bun --filter parson-music-web dev
+```
+
+Open `http://localhost:3000`. The embedded production API/UI origin is `http://localhost:1993`.
+
+## Repository layout
+
+| Path | Purpose |
+| --------------------------- | -------------------------------------------------------------------------------- |
+| `crates/parson-core` | Product-independent accounts and library registry boundary |
+| `crates/backend` | Rust music server, indexer, database, API, discovery, lyrics, and playback state |
+| `crates/windows-server` | Native Windows notification-area server host and updater |
+| `apps/web` | Next.js web UI embedded by native/server releases |
+| `apps/desktop` | Shared Linux and Windows Electron shell and native packaging |
+| `apps/mobile` | Expo/React Native client for Android and iOS |
+| `packages/parson-music-sdk` | Typed TypeScript API client |
+| `apps/site` | The Parson website and documentation |
+
+## Tests
+
+Run the checks proportional to your change. The release suite includes TypeScript type checks and tests, web and site builds, mobile linting, Rust format/clippy/tests/build, dependency audits, and desktop packaging.
+
+## Contributions
+
+Keep product names, API ownership, request budgets, bounded collections, and local-first privacy behavior intact. Add regression tests for behavior changes. Do not commit secrets, private libraries, copyrighted recordings, or personal diagnostic data.
+
+## Release process
+
+Parson follows Semantic Versioning. Maintainers build and verify platform artifacts, sign release packages, publish the differentiated container tag, validate updater metadata/checksums, and smoke-test fresh install, migration, discovery, streaming, and rollback paths.
+
+## API and protocol compatibility
+
+- `/api/core/v1` owns accounts, discovery, setup, and library registrations.
+- `/api/music/v1` owns catalog, search, streaming, lyrics, playlists, queue, and playback behavior.
+- `/api/v1` remains a compatibility alias for existing clients.
+- `/.well-known/parson` exposes `protocolVersion` and capabilities for client negotiation.
+
+The TypeScript SDK is the practical API reference today. There is no separately versioned OpenAPI publication in Parson 1.0.x. Clients should check the discovery protocol version and ignore unknown JSON fields for forward compatibility.
+
+## Licensing
+
+Parson's original code is licensed `GPL-3.0-only`. Third-party dependencies and inherited template portions retain their own notices. Distributing modified binaries requires compliance with the GPL and applicable third-party terms.
diff --git a/apps/site/content/docs/diagnostics.mdx b/apps/site/content/docs/diagnostics.mdx
new file mode 100644
index 00000000..7914ea2c
--- /dev/null
+++ b/apps/site/content/docs/diagnostics.mdx
@@ -0,0 +1,46 @@
+---
+title: Diagnostics
+description: Logs, health checks, version information, and safe support bundles.
+---
+
+## Logs
+
+On Windows, logs are under:
+
+```text
+%LOCALAPPDATA%\Parson\Logs
+```
+
+The notification-area menu provides **Open logs**. The server writes daily rolling `server.log` files and records its version at startup. Container deployments write structured logs to standard output unless the operator redirects them.
+
+## Export diagnostics
+
+Parson 1.0.x does not yet include a one-click diagnostics exporter. For support, collect only:
+
+- the relevant log window;
+- the response from `/health/ready`;
+- Parson and Windows/Android versions;
+- the failed scan's timing/warning summary; and
+- reproducible steps.
+
+Do not send databases, `Secrets`, music files, full artwork caches, or the entire data directory unless you have explicitly agreed on a private recovery process.
+
+## Redaction
+
+Logs are **not guaranteed to be fully redacted**. They can contain library paths, request paths, hostnames/addresses, account IDs, error details, and the temporary first-setup code. Review them before sharing. Remove usernames, local paths, addresses, tokens, cookies, setup codes, and any track metadata you consider private.
+
+Parson does not intentionally log password values or the full `session.key`.
+
+## Health checks
+
+| Endpoint | Meaning |
+| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GET /health` | Process is running and can answer HTTP |
+| `GET /health/ready` | Database and library lifecycle are usable; includes version, uptime, database-pool state, library state/message, and recovery-snapshot count |
+| `GET /.well-known/parson` | Discovery identity, protocol version, product/server version, friendly name, and capabilities |
+
+`/health/ready` returns `503` when the database is unavailable or the library is failed; indexing/empty states are described in its JSON body.
+
+## Version information
+
+Use the notification-area host, installed-app details, or the `serverVersion` value in `/.well-known/parson`. Include both server and client versions when reporting a cross-device issue.
diff --git a/apps/site/content/docs/download-and-install.mdx b/apps/site/content/docs/download-and-install.mdx
new file mode 100644
index 00000000..55ee8a88
--- /dev/null
+++ b/apps/site/content/docs/download-and-install.mdx
@@ -0,0 +1,40 @@
+---
+title: Download and install
+description: Requirements, updates, removal, and where the Windows app keeps its data.
+---
+
+## Requirements
+
+| App | Requirement |
+| ------------------ | --------------------------------------------------------------------------- |
+| Parson for Windows | 64-bit Windows 10 version 2004 (build 19041) or newer; Windows 11 supported |
+| Parson for Android | Android 8.0 (API 26) or newer |
+| Web player | A current browser that can reach the Parson host |
+
+Windows includes Electron's Chromium runtime, matching the Linux desktop rendering and audio surface. Some audio formats may need FFmpeg available to the server when they cannot be played directly by the client.
+
+## Install on Windows
+
+Download the latest signed installer from the official Parson release page, open it, and follow the Windows installation prompt. The Windows app contains both the music server and player; no separate server install is needed.
+
+## Update behavior
+
+The Windows notification-area host has **Check and install updates**. It downloads the official manifest and installer from Parson's GitHub releases, verifies the declared size and SHA-256 digest, stops the server, replaces it, and relaunches it. If replacement fails, it restores the previous executable.
+
+Updates do not replace your music or intentionally clear Parson data. Database migrations can make older application versions unable to read newer data; see [Updates and migrations](/docs/updates-and-migrations).
+
+## Uninstall
+
+Uninstall Parson from **Settings → Apps → Installed apps**. Your music files are never owned by Parson and remain where they are.
+
+Application data may remain so a reinstall can recover the library and accounts. Remove `%LOCALAPPDATA%\Parson` separately only when you intend to erase Parson's database, accounts, playlists, likes, history, lyrics cache, settings, logs, and cached artwork.
+
+## Application data
+
+The default Windows location is:
+
+```text
+%LOCALAPPDATA%\Parson
+```
+
+Important subfolders are `Database`, `Cache`, `Secrets`, `Logs`, `Discovery`, and `Updates`. `PARSON_DATA_DIR` can move the whole data directory in advanced installations.
diff --git a/apps/site/content/docs/index.mdx b/apps/site/content/docs/index.mdx
new file mode 100644
index 00000000..4026f36f
--- /dev/null
+++ b/apps/site/content/docs/index.mdx
@@ -0,0 +1,37 @@
+---
+title: Home
+---
+
+Parson is a **local-first, self-hosted music server and player**. Windows indexes music you own and serves the web and Android players. Your library, accounts, playlists, likes, and history stay on the Parson host.
+
+## Get started
+
+
+
+## Find an answer
+
+
diff --git a/apps/site/content/docs/library.mdx b/apps/site/content/docs/library.mdx
new file mode 100644
index 00000000..eb194d69
--- /dev/null
+++ b/apps/site/content/docs/library.mdx
@@ -0,0 +1,58 @@
+---
+title: Library
+description: Add folders and understand how Parson turns imperfect files into a useful music library.
+---
+
+## Adding your music
+
+During setup, choose the folder that contains your music and select **Use this folder**. After setup, add or refresh roots from **Settings → Library**. Each selected folder is registered independently.
+
+You can select one or more:
+
+- local folders and local drives;
+- removable or external drives; and
+- network folders already mounted and readable by the Windows account running Parson.
+
+Parson reads files in place. It does not rename, move, or rewrite the source audio.
+
+### When a folder is unavailable
+
+An unavailable root fails that refresh, but Parson preserves the last known-good catalog from other roots—and the previous available catalog—rather than replacing everything with an empty library. Reconnect or remount the folder, then refresh it. Tracks on the offline root cannot play until the path is readable again.
+
+## How Parson organizes music
+
+Parson uses tags first, then bounded, deterministic inference when tags are incomplete or inconsistent. Folder names are evidence, not an instruction to mirror the disk layout.
+
+### Artists and album artists
+
+Track artists are the performers credited on a recording. Album artists own the release in the library. When album-artist tags are present, Parson uses them. When they are missing, it considers the artists shared across tracks in the same album folder and known aliases. Featuring credits stay attached to tracks without fragmenting the album artist unnecessarily.
+
+### Albums and editions
+
+Albums are identified from normalized album title plus resolved album artist. Parson recognizes edition qualifiers such as deluxe, anniversary, remastered, expanded, special, collector's, limited, legacy, reissue, regional, mono/stereo, and bonus editions. It links confident variants into a release group while preserving the edition's visible title and tracks.
+
+An original release and its deluxe edition remain separate albums. If an edition has no suitable cover, it may inherit the original release's artwork; its own good square cover always wins.
+
+### Discs
+
+Disc numbers order tracks inside one release. Folder or title suffixes such as `CD 1`, `Disc 2`, and `Disk 1 of 2` are understood as disc evidence, not automatically as separate albums. Release-level artwork can be found above a disc subfolder.
+
+### Compilations
+
+An album artist such as **Various Artists**, a compilation folder, or strong multi-artist evidence classifies a release as a compilation. Individual track artists remain searchable and visible.
+
+### Singles, remixes, live releases, and bootlegs
+
+Parson classifies releases using a combination of album title, folder hierarchy, track count, track-title markers, and album artist. Categories include Album, EP, Single, Remix, Compilation, Live, Demos & Rarities, Bootleg, Soundtrack, Acapella, and Bonus Audio. A single weak word is not enough when the rest of the evidence disagrees.
+
+### Loose tracks
+
+A playable file still enters the library when its folder is not a conventional album layout. Its embedded tags provide the artist, album, title, track and disc numbers when available. Missing album context falls back conservatively rather than guessing a famous release.
+
+### Missing or malformed tags
+
+Parson normalizes Unicode, whitespace, dates, artist identities, track numbers, and common separators. Missing values fall back to safe labels such as **Unknown Artist**. Invalid dates and numbers are ignored. A file that cannot be parsed is skipped and recorded as a bounded scan warning; it does not fail the whole library.
+
+## Avoiding accidental merges
+
+Two recordings are treated as duplicates only when their release slot, normalized title, artist context, and duration agree closely. Editions stay distinct from originals. If two albums still merge or one album splits, correct the album/album-artist tags or use Parson's metadata editor, then rescan. See [Troubleshooting](/docs/troubleshooting#an-album-is-split).
diff --git a/apps/site/content/docs/listening.mdx b/apps/site/content/docs/listening.mdx
new file mode 100644
index 00000000..af537268
--- /dev/null
+++ b/apps/site/content/docs/listening.mdx
@@ -0,0 +1,34 @@
+---
+title: Listening
+description: Playback, queue controls, seeking, audio behavior, and media keys.
+---
+
+## Playback
+
+Select a song or an album's play button. Parson streams the source file when the client can play it and uses FFmpeg transcoding when needed. The Windows app, web player, and Android app share the same library and account data, but playback itself happens on the selected device unless you cast.
+
+## Queue
+
+Starting an album or collection creates an ordered queue. Song menus can play now, play next, or add to the queue. The web player persists the active server-side queue and position so the session can recover after navigation or a restart.
+
+## Shuffle and repeat
+
+Use **Shuffle** to create a randomized play order from a collection. Repeat cycles through off, repeat all, and repeat one where the client exposes all three modes. The current Android native surface exposes repeat-one; the web surface also provides repeat controls and keyboard shortcuts.
+
+## Seeking and gapless playback
+
+Drag the timeline or use platform seek controls. Range requests let direct-play clients seek without downloading the full file first.
+
+Parson does not currently advertise guaranteed gapless playback. Results depend on the client, format, encoder padding, and whether transcoding is required.
+
+## ReplayGain and normalization
+
+ReplayGain tag application and loudness normalization are not implemented in Parson 1.0.x. Volume and optional audio effects act on the current player output; they do not rewrite files.
+
+## Audio output
+
+Choose the output device in Windows or Android system audio controls. The web player does not currently include a per-device audio-output picker. Google Cast is available on supported Android/web setups and uses the server-owned shared queue.
+
+## Media keys
+
+The web player registers browser Media Session actions for play, pause, previous, next, and seeking where the browser supports them. Windows hardware media keys therefore work through the system/browser integration. Android publishes playback controls to the notification and lock screen.
diff --git a/apps/site/content/docs/lyrics.mdx b/apps/site/content/docs/lyrics.mdx
new file mode 100644
index 00000000..8b747073
--- /dev/null
+++ b/apps/site/content/docs/lyrics.mdx
@@ -0,0 +1,30 @@
+---
+title: Lyrics
+description: Where lyrics come from, how synced lines work, and what stays cached.
+---
+
+## How lookup works
+
+When you open lyrics, Parson checks its memory and persistent cache. If there is no valid cached result, the **Windows host** asks LRCLIB using the track title, artist, album, and duration. It tries an exact match first, then bounded search fallbacks. The phone does not contact LRCLIB directly.
+
+The request may reveal those four metadata fields and your host's public IP address to LRCLIB. Audio, account details, file paths, playlists, likes, and history are not sent.
+
+## Synced and plain lyrics
+
+Synced lyrics contain timestamps and follow playback line by line. Plain lyrics are displayed as text without timing. When LRCLIB returns both, Parson uses synced lines for the active view and retains plain text as a fallback.
+
+## Cached lyrics
+
+Successful and unsuccessful lookups are cached to avoid repeated requests. The persistent cache is stored at:
+
+```text
+%LOCALAPPDATA%\Parson\Cache\Lyrics\lyrics.db
+```
+
+Cached lyrics are also indexed locally for lyric search. Cache entries expire and can be refreshed by normal use.
+
+## Local lyrics and providers
+
+Parson 1.0.x does not read embedded lyric tags or `.lrc` files, so there is no local-lyrics priority. LRCLIB is the only built-in provider. A custom provider endpoint and a lyrics-specific local-only switch are not currently available.
+
+For a fully local deployment, simply do not open uncached lyrics or block `lrclib.net` at the host firewall. Already cached lyrics remain available until their cache entry expires.
diff --git a/apps/site/content/docs/meta.json b/apps/site/content/docs/meta.json
new file mode 100644
index 00000000..3305306b
--- /dev/null
+++ b/apps/site/content/docs/meta.json
@@ -0,0 +1,27 @@
+{
+ "title": "Parson",
+ "pages": [
+ "index",
+ "start-here",
+ "download-and-install",
+ "---Library---",
+ "library",
+ "supported-files",
+ "rescanning",
+ "---Use Parson---",
+ "listening",
+ "lyrics",
+ "playlists-likes-history",
+ "connecting-devices",
+ "advanced-networking",
+ "---Keep it safe---",
+ "accounts-privacy-security",
+ "data-safety",
+ "updates-and-migrations",
+ "---Reference---",
+ "troubleshooting",
+ "performance",
+ "diagnostics",
+ "development"
+ ]
+}
diff --git a/apps/site/content/docs/performance.mdx b/apps/site/content/docs/performance.mdx
new file mode 100644
index 00000000..e33f7aa6
--- /dev/null
+++ b/apps/site/content/docs/performance.mdx
@@ -0,0 +1,55 @@
+---
+title: Performance and indexing
+description: What indexing measures, what a benchmark must disclose, and the current reference results.
+---
+
+## Indexing explained
+
+**Indexing** is the complete operation that turns filesystem state into a searchable, playable catalog. It includes:
+
+1. **Discovery** — enumerate supported audio and image files and collect path, size, modified time, and optional stable identity.
+2. **Metadata parsing** — read container/tag regions for titles, artists, album artists, albums, dates, disc/track numbers, duration, genres, IDs, and embedded-art offsets.
+3. **Database insertion** — stage normalized file and catalog records in bounded batches, then atomically commit them.
+4. **Normalization and inference** — resolve artist aliases, album artists, editions, release groups, types, duplicates, and presentation data.
+5. **Search-index generation** — build normalized artist, album, track, genre, and cached-lyrics search documents.
+6. **Artwork processing** — choose embedded or external covers and create/cache usable derivatives.
+7. **Catalog export** — build the bounded in-memory read model clients use.
+
+### Cold scan and rescan
+
+A **cold scan** has no reusable file snapshots and parses the full library. A **warm rescan** discovers the root but reuses unchanged metadata. An **incremental scan** parses only changed/new files and rebuilds affected catalog projections. A manual repair additionally forces full filesystem reconciliation; it does not necessarily reparse unchanged media.
+
+## Production timing fields
+
+Every completed scan can report enumeration, parsing wall time, parse overlap, bytes read, file opens, metadata operations, reads, seeks, parser fallbacks, parser threads, storage queue depth, CPU time/utilization, unchanged detection, cover discovery, tag parsing, duration analysis, database staging/commit, inference, catalog export, and total wall time.
+
+## Published reference results
+
+These are engineering fixtures, not a promise for user libraries:
+
+| Dataset / procedure | Cache state | Raw result |
+| ----------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------- |
+| 100,000 tracks | Cold metadata and database state | About 64.8 s (roughly 1 min 5 s); about 1,542 tracks/s |
+| 1,000,000 synthetic tracks; build search index, then 1,000 searches | Generated fixture in process | 5.625 s build; 60 ms total search (60 µs mean) |
+| 1,000,000 history rows + 1,000,000 likes; indexed 100-row deep reads | Warm SQLite fixture | 62.4 µs history; 33.1 µs likes |
+| 100,000 valid audio paths in 1,000 album folders; clean enriched import | Fixture run, 2026-07-17 | 15.66 s |
+| Same 100,000-path fixture; unchanged scan | Warm metadata/database state | 6.74 s total: 0.40 s enumeration, 0.30 s unchanged detection, 5.23 s staging, 0.37 s export |
+
+Real scan time can differ substantially. CPU and available threads, SSD versus hard drive or network storage, filesystem cache state, average file size, audio-format mix, malformed tags, duration frame scans, embedded artwork, folder layout, antivirus activity, and other system load all affect throughput. Warm and incremental rescans are also not comparable to a cold first scan.
+
+The historical fixture record does not identify every hardware and storage detail, so these numbers are **informational only** and are not presented as a release guarantee. Memory use was not recorded for those runs. Search latency is shown above; scan memory is bounded by the implementation but not published as a measured figure here.
+
+## Benchmark publication standard
+
+Any result labeled a Parson benchmark must publish:
+
+- CPU, core/thread count, RAM, OS, and Parson commit/version;
+- storage model, interface, filesystem, and whether it is local or networked;
+- dataset source or generator, format mix, file count, album/artist count, total bytes, and library size;
+- cold/warm cache state and exactly how caches were evicted or preserved;
+- exact command or procedure and configuration overrides;
+- raw per-run results, not only the best run;
+- peak resident memory, CPU time/utilization, I/O counts/bytes, and database size; and
+- search index time plus p50/p95/p99 query latency.
+
+Run the external-library cold/warm benchmark only with `PARSON_TEST_LIBRARY` set to the intended music root—never a parent volume. The profile-guided build helpers live in `crates/backend/tools`.
diff --git a/apps/site/content/docs/playlists-likes-history.mdx b/apps/site/content/docs/playlists-likes-history.mdx
new file mode 100644
index 00000000..b994b207
--- /dev/null
+++ b/apps/site/content/docs/playlists-likes-history.mdx
@@ -0,0 +1,22 @@
+---
+title: Playlists, likes, and history
+description: Personal library data and the small set of things worth backing up.
+---
+
+## Creating playlists
+
+Open a song or album menu and choose **Add to playlist**, or create an empty playlist from the library. A playlist can contain up to 5,000 unique tracks; an account can own up to 1,000 playlists. Removing a track from a playlist does not remove the source file or library entry.
+
+## Liked songs
+
+Select the heart on a song to add it to **Liked songs**. Likes are private to the current account and sync between clients because they live on the host.
+
+## Listening history
+
+Parson records qualified playback locally to power recent listening and recommendations. Raw history is bounded to roughly 20,000 recent records per user; compact lifetime aggregates retain preference signals without letting the event table grow forever.
+
+## Export and backup
+
+There is no separate playlist/likes/history export in Parson 1.0.x. These records live in `parson-music.db`, so back up the Parson data directory to preserve them together. Restoring only the music files does not restore accounts, playlists, likes, or history.
+
+See [Backup and restore](/docs/data-safety) for the exact folders and a move-to-new-computer checklist.
diff --git a/apps/site/content/docs/rescanning.mdx b/apps/site/content/docs/rescanning.mdx
new file mode 100644
index 00000000..a248197f
--- /dev/null
+++ b/apps/site/content/docs/rescanning.mdx
@@ -0,0 +1,25 @@
+---
+title: Rescanning and file changes
+description: What happens after files are added, renamed, moved, deleted, or retagged.
+---
+
+## Automatic file watching
+
+Parson starts a recursive native filesystem watcher for each library root. It waits briefly for copying to settle, then performs an incremental refresh. A periodic full reconciliation—normally every 24 hours—repairs missed or overflowed notifications. If watching is unavailable, Parson falls back to scheduled reconciliation.
+
+## What changes do
+
+| Change | Result |
+| -------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| New file | Parsed and added after the copy settles |
+| Renamed file | Reconciled by path and, where enabled, stable file identity |
+| Moved file | Removed from the old path and indexed at the new path; its catalog identity is retained when tags identify the same track |
+| Deleted file | Removed on refresh; the rest of the catalog remains available |
+| Tag change | Reparsed when file size or modified time changes; affected artists, albums, releases, and search records are rebuilt |
+| Artwork change | Cover selection and derivatives are refreshed for affected releases |
+
+## Manual rescan
+
+Use **Settings → Library → Refresh** when you do not want to wait, after reconnecting an external root, or when a tool preserved a file's old modified time. A normal refresh uses incremental discovery. Repair diagnostics can force a complete filesystem reconciliation.
+
+Unchanged files reuse stored metadata. They are not fully reparsed on every rescan.
diff --git a/apps/site/content/docs/start-here.mdx b/apps/site/content/docs/start-here.mdx
new file mode 100644
index 00000000..54ceae24
--- /dev/null
+++ b/apps/site/content/docs/start-here.mdx
@@ -0,0 +1,32 @@
+---
+title: Start here
+description: From installer to first song in a few minutes.
+---
+
+## 1. Install Parson on Windows
+
+Download the latest **Parson for Windows** installer, open it, and follow Windows' prompts. Parson supports 64-bit Windows 10 version 2004 or newer, including Windows 11.
+
+## 2. Create the first account
+
+Open Parson. Enter a username and a password of at least 8 characters. The first account becomes the administrator.
+
+If setup is opened directly on the Windows host, nothing else is required. Setup from another device asks for the 12-character code shown in the server log.
+
+## 3. Choose a music folder
+
+Select **Choose folder**, open the folder that contains your music, then select **Use this folder**. A folder can be on a local drive, external drive, or a network location already mounted in Windows.
+
+## 4. Wait for the initial scan
+
+Parson discovers files, reads tags, builds the library and search index, and prepares artwork. You can leave the page open; progress updates automatically. Large libraries and slower external drives take longer.
+
+## 5. Start listening
+
+Open an album, artist, or song and press **Play**. The player stays available at the bottom of the app. Search, queue, repeat, seeking, lyrics, playlists, likes, and history work without further setup.
+
+## 6. Connect Android
+
+Keep Parson running on Windows and put both devices on the same trusted network. Open Parson on Android, select the nearby library, and sign in. If it does not appear, use **Enter address**; see [Connect Android](/docs/connecting-devices).
+
+That is the normal setup. You do not need to configure a port, hostname, or reverse proxy for use at home.
diff --git a/apps/site/content/docs/supported-files.mdx b/apps/site/content/docs/supported-files.mdx
new file mode 100644
index 00000000..6a127724
--- /dev/null
+++ b/apps/site/content/docs/supported-files.mdx
@@ -0,0 +1,35 @@
+---
+title: Supported files
+description: Audio, artwork, lyrics, and playlist-file support.
+---
+
+## Audio formats
+
+Parson indexes these file extensions:
+
+| Format | Extensions | Notes |
+| ------------------ | ---------- | ------------------------------------------------- |
+| MP3 | `.mp3` | Direct play in normal clients |
+| FLAC | `.flac` | Direct play where supported; otherwise transcoded |
+| Ogg Vorbis | `.ogg` | Client support varies |
+| AAC / MPEG-4 audio | `.m4a` | Includes common AAC and Apple Lossless containers |
+| Opus | `.opus` | Client support varies |
+| WAV | `.wav` | Large, usually direct play |
+| AIFF | `.aiff` | May require transcoding |
+| Apple Lossless | `.alac` | May require transcoding |
+
+Format detection uses the extension and validates the file while reading metadata. FFmpeg is required only when Parson must transcode for a client that cannot play the source directly.
+
+## Artwork
+
+Parson reads embedded artwork and external `.jpg`, `.jpeg`, `.png`, and `.webp` images. It favors likely front-cover names and square, useful images near the album, while rejecting tiny icons and unsuitable wide booklet art. Common names such as `cover`, `folder`, `front`, and album-title images work well. Artwork in a release folder can serve tracks inside `CD 1` or `Disc 2` subfolders.
+
+You can upload a replacement album cover from the metadata editor. Parson stores generated and uploaded artwork in its data directory, never inside the music folder unless you put it there yourself.
+
+## Lyrics
+
+The current release does **not** read embedded lyric tags or `.lrc` sidecar files. Lyrics come from LRCLIB and may be synced or plain; see [Lyrics](/docs/lyrics). Local-lyrics priority is therefore not applicable in Parson 1.0.x.
+
+## Playlist files
+
+Parson 1.0.x stores playlists in its database. Importing or exporting `.m3u`, `.m3u8`, `.pls`, or other playlist files is not currently supported. Back up playlists with the Parson data directory.
diff --git a/apps/site/content/docs/troubleshooting.mdx b/apps/site/content/docs/troubleshooting.mdx
new file mode 100644
index 00000000..73ccdbb1
--- /dev/null
+++ b/apps/site/content/docs/troubleshooting.mdx
@@ -0,0 +1,108 @@
+---
+title: Troubleshooting
+description: Find the symptom, try the quick fix, then inspect only what matters.
+---
+
+## Parson cannot find my music
+
+**Cause →** The selected path is wrong, unreadable by the Windows account, excluded, or contains no supported extensions.
+
+**Quick fix →** Open **Settings → Library**, select the folder that directly contains the library, and refresh. Confirm at least one file uses a [supported extension](/docs/supported-files).
+
+**Deeper diagnostics →** Check the scan event and `%LOCALAPPDATA%\Parson\Logs`. Verify permissions, hidden/system attributes, and `PARSON_SCAN_EXCLUDED_DIRS` if configured.
+
+## An album is split
+
+**Cause →** Album-title or album-artist tags disagree, or one group has an edition qualifier that correctly makes it a separate release.
+
+**Quick fix →** Compare **Album** and **Album artist** across the tracks, correct the outlier in Parson's metadata editor or your tagger, then refresh.
+
+**Deeper diagnostics →** Compare disc numbers, release dates, MusicBrainz release IDs, normalized spelling, and folder boundaries. Keep true deluxe/remastered editions distinct.
+
+## Two albums were merged
+
+**Cause →** Tracks share the same normalized album title and resolved album artist, and there is not enough edition/release evidence to separate them.
+
+**Quick fix →** Give each release an accurate album artist and distinct edition title, such as `Album (Deluxe Edition)`, then refresh.
+
+**Deeper diagnostics →** Check release IDs, dates, disc tags, durations, and source folders. Report a reproducible false merge with sanitized tags if the evidence is already distinct.
+
+## Artwork is wrong or missing
+
+**Cause →** No usable embedded/external image was found, a nearby image ranked higher, or the cache still represents old files.
+
+**Quick fix →** Place a square `cover.jpg` in the release folder or upload a cover in the album editor, then refresh.
+
+**Deeper diagnostics →** Check for tiny icons, wide booklet scans, multiple `front`/`folder` images, permissions, and disc-folder placement. Inspect scan warnings and artwork cache writes.
+
+## Duplicate tracks appear
+
+**Cause →** The same recording exists in multiple roots or files differ enough in album slot, artist, title, or duration to avoid duplicate reconciliation.
+
+**Quick fix →** Remove the overlapping library root or correct the duplicate files' tags, then run a manual refresh.
+
+**Deeper diagnostics →** Compare normalized title, album artist, album, disc/track number, duration, and path. File copies on different releases may be intentional and remain visible in each release.
+
+## A track will not play
+
+**Cause →** The source is offline/corrupt, the client cannot direct-play it, or FFmpeg is unavailable for required transcoding.
+
+**Quick fix →** Confirm the file plays locally, reconnect its drive, and try another client. Install FFmpeg on the server `PATH` when transcoding is required.
+
+**Deeper diagnostics →** Check the stream request in server logs, file permissions, range responses, codec/container details, and free space in the transcode cache.
+
+## Lyrics are unavailable
+
+**Cause →** LRCLIB has no confident match, the host is offline, the provider timed out, or a negative result is cached.
+
+**Quick fix →** Verify title, artist, album, and duration, then try later.
+
+**Deeper diagnostics →** Check host access to `lrclib.net` and server logs for `lyrics_not_found`, `lyrics_provider_unavailable`, or capacity errors. Inspect `Cache\Lyrics\lyrics.db` only with Parson stopped.
+
+## Android cannot find Parson
+
+**Cause →** Devices are on different networks, Nearby permission is denied, multicast discovery is blocked, or Parson is loopback-only.
+
+**Quick fix →** Put both devices on the same Wi-Fi, allow Nearby devices, restart discovery, or enter the server origin manually.
+
+**Deeper diagnostics →** Open `http://HOST:1993/.well-known/parson` from the phone, check Windows Firewall and AP isolation, and verify `_parson._tcp.local.` advertising in logs.
+
+## Remote access stopped working
+
+**Cause →** The host slept, public address/DNS changed, TLS expired, the proxy lost WebSocket/range support, or VPN routing changed.
+
+**Quick fix →** Wake the host, verify the VPN/proxy, and open the configured HTTPS origin in a browser.
+
+**Deeper diagnostics →** Test `/health` and `/health/ready`, inspect proxy and Parson logs, confirm `PARSON_PUBLIC_URL`, certificate validity, forwarded host, WebSocket upgrades, and firewall rules.
+
+## Scan appears stuck
+
+**Cause →** A large cold scan, slow/network storage, an in-progress copy, a difficult file, or database contention can make a phase look quiet.
+
+**Quick fix →** Leave Parson running and check whether the file count or phase changes. Do not repeatedly restart a healthy scan.
+
+**Deeper diagnostics →** Inspect scan events and logs for enumeration, parsing, database staging, commit, inference, artwork, and export timings. Look for one unreadable file or an unavailable root.
+
+## Parson uses too much memory or CPU
+
+**Cause →** Initial parsing, artwork processing, transcoding, or a very large search/catalog build is active.
+
+**Quick fix →** Let the scan finish, stop unused streams, and avoid scanning a parent drive instead of the music root.
+
+**Deeper diagnostics →** Compare activity with scan and transcode logs. Advanced operators can reduce `PARSON_SCAN_THREADS`, parser threads, or transcode concurrency; record the change because it trades speed for resource use.
+
+## The Windows app opens blank
+
+**Cause →** The embedded server is still starting, Electron failed to load the product surface, or static assets from different versions are mixed.
+
+**Quick fix →** Exit Parson from the notification area, start it again, and open `http://127.0.0.1:1993` in a browser.
+
+**Deeper diagnostics →** Check `Logs`, `/health/ready`, free disk space, and antivirus quarantine. Reinstall the same version without deleting the data directory.
+
+## The library folder is offline
+
+**Cause →** An external drive, mapped share, NAS, or its credentials are unavailable to the account running Parson.
+
+**Quick fix →** Reconnect the drive/share using the same path and refresh that root.
+
+**Deeper diagnostics →** Verify drive letter or UNC mapping after sign-in, service-account permissions, sleep behavior, and network availability. Parson preserves the prior catalog, but those tracks cannot stream until the source returns.
diff --git a/apps/site/content/docs/updates-and-migrations.mdx b/apps/site/content/docs/updates-and-migrations.mdx
new file mode 100644
index 00000000..f0b217b7
--- /dev/null
+++ b/apps/site/content/docs/updates-and-migrations.mdx
@@ -0,0 +1,26 @@
+---
+title: Updates and migrations
+description: Automatic updates, database changes, rollback limits, and sensible precautions.
+---
+
+## Automatic updates
+
+The Windows notification-area host updates on demand through **Check and install updates**. It downloads the official update manifest and executable, verifies size and SHA-256, stops the server, replaces the executable, and relaunches it. A failed executable replacement rolls back automatically.
+
+Parson does not silently replace the application while you are listening.
+
+## Database migrations
+
+Parson applies bundled SQLite migrations when a newer version first opens the data directory. Migrations cover schema, indexes, and compatibility transitions such as the move from `music.db` to `parson-music.db`.
+
+Before a major or operationally important update:
+
+1. stop Parson;
+2. back up `%LOCALAPPDATA%\Parson`; and
+3. keep the installer or executable for the version you are leaving.
+
+## Rollback limitations
+
+The updater can restore an executable when replacement itself fails. It cannot promise that an older executable can read a database already migrated by a newer release. Restoring the old application may also require restoring the matching pre-update data backup.
+
+Do not copy one database file from a newer data folder into an older partial backup. Accounts and music data are separated across two databases and should be restored as one consistent data directory.
diff --git a/apps/site/lib/get-llm-text.ts b/apps/site/lib/get-llm-text.ts
new file mode 100644
index 00000000..61d52699
--- /dev/null
+++ b/apps/site/lib/get-llm-text.ts
@@ -0,0 +1,6 @@
+import { source } from "./source";
+
+export async function getLLMText(page: (typeof source)["$inferPage"]) {
+ const processed = await page.data.getText("processed");
+ return `# ${page.data.title}\n\nSource: ${page.url}\n\n${processed}`;
+}
diff --git a/apps/site/lib/layout.shared.tsx b/apps/site/lib/layout.shared.tsx
new file mode 100644
index 00000000..68d02b09
--- /dev/null
+++ b/apps/site/lib/layout.shared.tsx
@@ -0,0 +1,12 @@
+import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
+
+export function baseOptions(): BaseLayoutProps {
+ return {
+ nav: {
+ enabled: false,
+ title: Documentation ,
+ transparentMode: "top",
+ },
+ themeSwitch: { enabled: false },
+ };
+}
diff --git a/apps/site/lib/source.ts b/apps/site/lib/source.ts
new file mode 100644
index 00000000..0fca17f4
--- /dev/null
+++ b/apps/site/lib/source.ts
@@ -0,0 +1,7 @@
+import { docs } from "../.source/server";
+import { loader } from "fumadocs-core/source";
+
+export const source = loader({
+ baseUrl: "/docs",
+ source: docs.toFumadocsSource(),
+});
diff --git a/apps/site/next.config.mjs b/apps/site/next.config.mjs
new file mode 100644
index 00000000..2e701c28
--- /dev/null
+++ b/apps/site/next.config.mjs
@@ -0,0 +1,21 @@
+import { createMDX } from "fumadocs-mdx/next";
+
+/** @type {import('next').NextConfig} */
+const config = {
+ reactStrictMode: true,
+ webpack(webpackConfig) {
+ // Webpack cannot statically trace Fumadocs' generated file-URL imports.
+ webpackConfig.infrastructureLogging = { level: "error" };
+ return webpackConfig;
+ },
+ async rewrites() {
+ return [
+ {
+ source: "/docs/:path*.md",
+ destination: "/llms.mdx/docs/:path*",
+ },
+ ];
+ },
+};
+
+export default createMDX()(config);
diff --git a/apps/site/open-next.config.ts b/apps/site/open-next.config.ts
new file mode 100644
index 00000000..ffd98878
--- /dev/null
+++ b/apps/site/open-next.config.ts
@@ -0,0 +1,3 @@
+import { defineCloudflareConfig } from "@opennextjs/cloudflare";
+
+export default defineCloudflareConfig();
diff --git a/apps/site/package.json b/apps/site/package.json
new file mode 100644
index 00000000..67a31f18
--- /dev/null
+++ b/apps/site/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "parson-site",
+ "version": "1.0.0",
+ "private": true,
+ "license": "GPL-3.0-only",
+ "scripts": {
+ "dev": "next dev --webpack",
+ "build": "next build --webpack",
+ "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
+ "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
+ "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
+ "type-check": "tsc --noEmit",
+ "start": "next start",
+ "screenshots": "node capture-screenshots.cjs",
+ "postinstall": "fumadocs-mdx"
+ },
+ "dependencies": {
+ "@opennextjs/cloudflare": "1.20.1",
+ "fumadocs-core": "16.11.5",
+ "fumadocs-mdx": "15.2.0",
+ "fumadocs-ui": "16.11.5",
+ "lucide-react": "1.24.0",
+ "next": "16.2.10",
+ "react": "19",
+ "react-dom": "19"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.3.2",
+ "@types/mdx": "^2.0.13",
+ "@types/node": "^26.1.1",
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "playwright": "1.61.1",
+ "postcss": "^8.5.19",
+ "tailwindcss": "^4.3.2",
+ "typescript": "6.0.3",
+ "wrangler": "4.112.0"
+ }
+}
diff --git a/apps/site/postcss.config.mjs b/apps/site/postcss.config.mjs
new file mode 100644
index 00000000..c2ddf748
--- /dev/null
+++ b/apps/site/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
diff --git a/apps/site/public/album-art/afterlight.webp b/apps/site/public/album-art/afterlight.webp
new file mode 100644
index 00000000..f85be067
Binary files /dev/null and b/apps/site/public/album-art/afterlight.webp differ
diff --git a/apps/site/public/album-art/amber-hours.webp b/apps/site/public/album-art/amber-hours.webp
new file mode 100644
index 00000000..5ac3087a
Binary files /dev/null and b/apps/site/public/album-art/amber-hours.webp differ
diff --git a/apps/site/public/album-art/distant-lines.webp b/apps/site/public/album-art/distant-lines.webp
new file mode 100644
index 00000000..3a90c3f0
Binary files /dev/null and b/apps/site/public/album-art/distant-lines.webp differ
diff --git a/apps/site/public/album-art/evergreen.webp b/apps/site/public/album-art/evergreen.webp
new file mode 100644
index 00000000..481e9c74
Binary files /dev/null and b/apps/site/public/album-art/evergreen.webp differ
diff --git a/apps/site/public/album-art/golden-state.webp b/apps/site/public/album-art/golden-state.webp
new file mode 100644
index 00000000..870b72e6
Binary files /dev/null and b/apps/site/public/album-art/golden-state.webp differ
diff --git a/apps/site/public/album-art/night-swim.webp b/apps/site/public/album-art/night-swim.webp
new file mode 100644
index 00000000..c1b0c57b
Binary files /dev/null and b/apps/site/public/album-art/night-swim.webp differ
diff --git a/apps/site/public/album-art/red-shift.webp b/apps/site/public/album-art/red-shift.webp
new file mode 100644
index 00000000..3113013a
Binary files /dev/null and b/apps/site/public/album-art/red-shift.webp differ
diff --git a/apps/site/public/album-art/still-form.webp b/apps/site/public/album-art/still-form.webp
new file mode 100644
index 00000000..84c85626
Binary files /dev/null and b/apps/site/public/album-art/still-form.webp differ
diff --git a/apps/site/public/album-art/violet-river.webp b/apps/site/public/album-art/violet-river.webp
new file mode 100644
index 00000000..6ede0b74
Binary files /dev/null and b/apps/site/public/album-art/violet-river.webp differ
diff --git a/apps/site/public/brand/tux.svg b/apps/site/public/brand/tux.svg
new file mode 100644
index 00000000..d925300e
--- /dev/null
+++ b/apps/site/public/brand/tux.svg
@@ -0,0 +1,439 @@
+
+
+
+ Tux
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/site/public/icons/apple-touch-icon.png b/apps/site/public/icons/apple-touch-icon.png
new file mode 100644
index 00000000..82b05283
Binary files /dev/null and b/apps/site/public/icons/apple-touch-icon.png differ
diff --git a/apps/site/public/icons/favicon-16x16.png b/apps/site/public/icons/favicon-16x16.png
new file mode 100644
index 00000000..5b3a459c
Binary files /dev/null and b/apps/site/public/icons/favicon-16x16.png differ
diff --git a/apps/site/public/icons/favicon-32x32.png b/apps/site/public/icons/favicon-32x32.png
new file mode 100644
index 00000000..a87d262a
Binary files /dev/null and b/apps/site/public/icons/favicon-32x32.png differ
diff --git a/apps/site/public/icons/icon-192.png b/apps/site/public/icons/icon-192.png
new file mode 100644
index 00000000..ad0379ab
Binary files /dev/null and b/apps/site/public/icons/icon-192.png differ
diff --git a/apps/site/public/icons/icon-512.png b/apps/site/public/icons/icon-512.png
new file mode 100644
index 00000000..593c0a00
Binary files /dev/null and b/apps/site/public/icons/icon-512.png differ
diff --git a/apps/site/public/icons/icon.svg b/apps/site/public/icons/icon.svg
new file mode 100644
index 00000000..5fa52ca0
--- /dev/null
+++ b/apps/site/public/icons/icon.svg
@@ -0,0 +1,4 @@
+
+
+ P
+
diff --git a/apps/site/public/icons/maskable-icon-192.png b/apps/site/public/icons/maskable-icon-192.png
new file mode 100644
index 00000000..6dac6a07
Binary files /dev/null and b/apps/site/public/icons/maskable-icon-192.png differ
diff --git a/apps/site/public/icons/maskable-icon-512.png b/apps/site/public/icons/maskable-icon-512.png
new file mode 100644
index 00000000..92fc6ca0
Binary files /dev/null and b/apps/site/public/icons/maskable-icon-512.png differ
diff --git a/apps/site/public/screenshots/01-home.png b/apps/site/public/screenshots/01-home.png
new file mode 100644
index 00000000..6c862254
Binary files /dev/null and b/apps/site/public/screenshots/01-home.png differ
diff --git a/apps/site/public/screenshots/02-library.png b/apps/site/public/screenshots/02-library.png
new file mode 100644
index 00000000..0c72b9f1
Binary files /dev/null and b/apps/site/public/screenshots/02-library.png differ
diff --git a/apps/site/public/screenshots/03-artist.png b/apps/site/public/screenshots/03-artist.png
new file mode 100644
index 00000000..dcd813d4
Binary files /dev/null and b/apps/site/public/screenshots/03-artist.png differ
diff --git a/apps/site/public/screenshots/04-album.png b/apps/site/public/screenshots/04-album.png
new file mode 100644
index 00000000..38383617
Binary files /dev/null and b/apps/site/public/screenshots/04-album.png differ
diff --git a/apps/site/public/screenshots/05-search.png b/apps/site/public/screenshots/05-search.png
new file mode 100644
index 00000000..fb18489e
Binary files /dev/null and b/apps/site/public/screenshots/05-search.png differ
diff --git a/apps/site/public/screenshots/06-now-playing.png b/apps/site/public/screenshots/06-now-playing.png
new file mode 100644
index 00000000..8742622f
Binary files /dev/null and b/apps/site/public/screenshots/06-now-playing.png differ
diff --git a/apps/site/public/screenshots/07-lyrics.png b/apps/site/public/screenshots/07-lyrics.png
new file mode 100644
index 00000000..beae41e4
Binary files /dev/null and b/apps/site/public/screenshots/07-lyrics.png differ
diff --git a/apps/site/source.config.ts b/apps/site/source.config.ts
new file mode 100644
index 00000000..86831f2e
--- /dev/null
+++ b/apps/site/source.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig, defineDocs } from "fumadocs-mdx/config";
+
+export const docs = defineDocs({
+ dir: "content/docs",
+ docs: {
+ postprocess: {
+ includeProcessedMarkdown: true,
+ },
+ },
+});
+
+export default defineConfig();
diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json
new file mode 100644
index 00000000..c5538df2
--- /dev/null
+++ b/apps/site/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"],
+ "collections/*": ["./.source/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ ".next/types/**/*.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.mdx",
+ ".source/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/site/wrangler.jsonc b/apps/site/wrangler.jsonc
new file mode 100644
index 00000000..cd008705
--- /dev/null
+++ b/apps/site/wrangler.jsonc
@@ -0,0 +1,14 @@
+{
+ "$schema": "./node_modules/wrangler/config-schema.json",
+ "name": "parson",
+ "main": ".open-next/worker.js",
+ "compatibility_date": "2026-07-19",
+ "compatibility_flags": ["nodejs_compat"],
+ "assets": {
+ "directory": ".open-next/assets",
+ "binding": "ASSETS",
+ },
+ "observability": {
+ "enabled": true,
+ },
+}
diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json
deleted file mode 100644
index bffb357a..00000000
--- a/apps/web/.eslintrc.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "extends": "next/core-web-vitals"
-}
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 710b09c4..18f20aed 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -1,6 +1,3 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
/node_modules
/.pnp
.pnp.js
@@ -9,36 +6,27 @@ yarn.lock
package-lock.json
bun.lockb
-# testing
/coverage
-# next.js
/.next/
/out/
-# production
/build
-# misc
.DS_Store
*.pem
-# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-# local env files
.env*
-# vercel
.vercel
-# typescript
*.tsbuildinfo
next-env.d.ts
-# PWA
/public/sw.js
/public/sw.js.map
/public/workbox-*.js
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 00000000..16c093ea
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,11 @@
+# Web
+
+Parson web client.
+
+## Commands
+
+```bash
+bun run dev
+bun run type-check
+bun run build
+```
diff --git a/apps/web/app/(admin)/admin/page.tsx b/apps/web/app/(admin)/admin/page.tsx
deleted file mode 100644
index 559c36f9..00000000
--- a/apps/web/app/(admin)/admin/page.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client"
-
-import { useEffect } from "react";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { useRouter } from "next/navigation";
-
-export default function AdminPage() {
- const { push } = useRouter();
-
- useEffect(() => {
- const session = getSession();
- if (session?.role !== "admin") {
- push("/home");
- }
- }, [push]);
-
- return (
- You are admin!
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(admin)/admin/users/page.tsx b/apps/web/app/(admin)/admin/users/page.tsx
deleted file mode 100644
index a8c7b69d..00000000
--- a/apps/web/app/(admin)/admin/users/page.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-import { Button } from "@music/ui/components/button"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form"
-import { Input } from "@music/ui/components/input"
-import { Checkbox } from "@music/ui/components/checkbox"
-import { register } from "@music/sdk"
-
-const formSchema = z.object({
- username: z.string().min(2, {
- message: "Username must be at least 2 characters.",
- }),
- password: z.string().min(6, {
- message: "Password must be at least 6 characters.",
- }),
- admin: z.boolean().default(false).optional(),
-})
-
-export default function UsersPage() {
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- username: "",
- password: "",
- admin: false,
- },
- })
-
- function onSubmit(values: z.infer) {
- const role = values.admin ? "admin" : "user"
- register({ username: values.username, password: values.password, role })
- }
-
- return (
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/app/(admin)/layout.tsx b/apps/web/app/(admin)/layout.tsx
deleted file mode 100644
index 31869a88..00000000
--- a/apps/web/app/(admin)/layout.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import "@music/ui/globals.css"
-import { Inter as FontSans } from "next/font/google"
-
-import pl from "@/assets/pl-tp.png"
-import { cn } from "@music/ui/lib/utils"
-import { Metadata } from "next"
-import Image from "next/image"
-import AdminPanelLayout from "@/components/Layout/Sidebar/AdminPanellayout"
-
-const fontSans = FontSans({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-export const metadata: Metadata = {
- title: "ParsonLabs Music"
-}
-
-type RootLayoutProps = {
- children: React.ReactNode
-}
-
-export default function RootLayout({ children }: RootLayoutProps) {
- return (
-
-
-
-
- {children}
-
-
-
- )
-}
-
-
diff --git a/apps/web/app/(app)/album/AlbumComponent.tsx b/apps/web/app/(app)/album/AlbumComponent.tsx
deleted file mode 100644
index 90fe21cd..00000000
--- a/apps/web/app/(app)/album/AlbumComponent.tsx
+++ /dev/null
@@ -1,330 +0,0 @@
-"use client";
-
-import Description from "@/components/Description/Description";
-import PageGradient from "@/components/Layout/PageGradient";
-import AlbumTable from "@/components/Music/Album/AlbumTable";
-import getBaseURL from "@/lib/Server/getBaseURL";
-import MusicbrainzLogo from "@/public/musicbrainz_icon.png";
-import DiscogsLogo from "@/public/discogs_icon.png";
-import WikidataLogo from "@/public/wikidata_logo.png";
-import WikipediaLogo from "@/public/wikipedia_logo.png";
-import AllmusicLogo from "@/public/AllmusicLogo.png";
-import RateyourmusicLogo from "@/public/RateyourmusicLogo.png";
-import PitchforkLogo from "@/public/PitchforkLogo.png";
-import BBCLogo from "@/public/BBCLogo.svg";
-import { getAlbumInfo, LibraryAlbum } from "@music/sdk";
-import { Artist } from "@music/sdk/types";
-import { Badge } from "@music/ui/components/badge";
-import { Button } from "@music/ui/components/button";
-import { ScrollArea, ScrollBar } from "@music/ui/components/scroll-area";
-import Image from "next/image";
-import Link from "next/link";
-import { redirect, useSearchParams } from "next/navigation";
-import { useEffect, useState } from "react";
-
-export default function AlbumComponent() {
- const searchParams = useSearchParams();
- const id = searchParams?.get("id");
-
- const [album, setAlbum] = useState(null);
- const [artist, setArtist] = useState(null);
-
- useEffect(() => {
- if (!id || typeof id !== "string") return;
-
- const fetchAlbum = async () => {
- const album = await getAlbumInfo(id);
- const artistData = album.artist_object;
-
- if (!artistData || !album) {
- redirect("/404");
- } else {
- setAlbum(album);
- setArtist(artistData);
- }
- };
-
- fetchAlbum();
- }, [id]);
-
- if (!album || !artist) {
- return null;
- }
-
- const albumCoverURL =
- album.cover_url.length === 0
- ? "/snf.png"
- : `${getBaseURL()}/image/${encodeURIComponent(album.cover_url)}?raw=true`;
-
- function formatDuration(duration: number) {
- const hours = Math.floor(duration / 3600);
- const minutes = Math.floor((duration % 3600) / 60);
- const seconds = Math.round(duration % 60);
-
- let result = "";
- if (hours > 0) {
- result += `${hours} Hour${hours > 1 ? "s" : ""} `;
- }
- if (minutes > 0) {
- result += `${minutes} Minute${minutes > 1 ? "s" : ""} `;
- }
- return result.trim();
- }
-
- let totalDuration = album.songs.reduce(
- (total, song) => total + song.duration,
- 0
- );
- let releaseDate = new Date(album.first_release_date).toLocaleString(
- "default",
- { month: "long", year: "numeric" }
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
{album.name}
-
-
- {album.release_group_album?.artist_credit.map(
- (artist) => (
- {artist.name}
- )
- )}
- {album.release_album?.information.artist_credits.map(
- (artist) => (
- {artist.name}
- )
- )}
-
-
-
- {album.release_group_album?.rating.value !== 0 &&
- renderStars(album.release_group_album?.rating.value || 0)}
-
-
{releaseDate}
-
-
{album.songs.length} Songs
-
•
-
{formatDuration(totalDuration)}
-
-
- {album.release_group_album?.genres.map((tag) => (
-
- {tag.name}
-
- ))}
- {album.release_album?.genres.map((tag) => (
-
- {tag.name}
-
- ))}
-
-
-
-
-
-
- {album.release_group_album?.relationships.map(
- (relationship) => (
-
-
-
-
-
- )
- )}
- {album.release_album?.relationships.map((relationship) => (
-
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-const renderStars = (rating: number) => {
- const fullStars = Math.floor(rating);
- const halfStar = rating % 1 >= 0.5 ? 1 : 0;
- const emptyStars = 5 - fullStars - halfStar;
-
- return (
- <>
- {"★".repeat(fullStars)}
- {halfStar ? "☆" : ""}
- {"☆".repeat(emptyStars)}
- >
- );
-};
diff --git a/apps/web/app/(app)/album/album-details.tsx b/apps/web/app/(app)/album/album-details.tsx
new file mode 100644
index 00000000..2efc6867
--- /dev/null
+++ b/apps/web/app/(app)/album/album-details.tsx
@@ -0,0 +1,362 @@
+"use client";
+
+import getBaseURL from "@/lib/api/server-url";
+import { getLibraryImageUrl } from "@/lib/images/image-url";
+import {
+ addAlbumToPlaylist as addAlbumTracksToPlaylist,
+ getPlaylists,
+ type LibraryAlbum,
+} from "@parson/music-sdk";
+import type { Artist } from "@parson/music-sdk/types";
+import Image from "next/image";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useMemo, useState } from "react";
+import {
+ Download,
+ ListEnd,
+ ListPlus,
+ Loader2,
+ MoreHorizontal,
+ Pause,
+ Play,
+ Plus,
+ RefreshCw,
+ UserRound,
+} from "lucide-react";
+import { downloadAlbum } from "@/lib/downloads/download-album";
+import { usePlayer } from "@/features/player/player-context";
+import { useSession } from "@/features/account/session-provider";
+import AlbumEditor from "@/features/library/album-editor";
+import { useAlbum } from "@/features/library/use-library-entity";
+import CreatePlaylistDialog from "@/features/library/create-playlist-dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ DropdownMenuItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from "@/components/ui/dropdown-menu";
+import { usePageTitle } from "@/components/app/title-metadata";
+import { useFitText } from "@/features/library/use-fit-text";
+import { AlbumTrackList } from "./album-track-list";
+import EntityPageState from "@/features/library/entity-page-state";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+function formatTotalDuration(duration: number) {
+ const minutes = Math.max(1, Math.round(duration / 60));
+ if (minutes < 60) return `${minutes} min`;
+
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+ return remainingMinutes
+ ? `${hours} hr ${remainingMinutes} min`
+ : `${hours} hr`;
+}
+
+function splitQualifiedTitle(title: string) {
+ const parenthesis = title.indexOf("(");
+ const bracket = title.indexOf("[");
+ const candidates = [parenthesis, bracket].filter((index) => index > 0);
+ const splitAt = candidates.length ? Math.min(...candidates) : -1;
+ if (splitAt < 0) return null;
+ return [title.slice(0, splitAt).trimEnd(), title.slice(splitAt)] as const;
+}
+
+type AlbumDetailsProps = {
+ devAlbum?: LibraryAlbum;
+};
+
+export default function AlbumDetails({ devAlbum }: AlbumDetailsProps = {}) {
+ const searchParams = useSearchParams();
+ const id = searchParams?.get("id");
+ const { session } = useSession();
+
+ const queryClient = useQueryClient();
+ const [actionsOpen, setActionsOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const playlists = useQuery({
+ queryKey: ["playlists"],
+ queryFn: getPlaylists,
+ enabled: actionsOpen,
+ });
+
+ const albumQuery = useAlbum(id, devAlbum);
+ const album = albumQuery.data ?? null;
+ const artist: Artist | null = album?.artist_object ?? null;
+ usePageTitle(
+ album ? [album.name, artist?.name].filter(Boolean).join(" - ") : null,
+ );
+ const {
+ fontSize: titleFontSize,
+ textRef: titleRef,
+ wrapped: titleWrapped,
+ wrapperRef: titleWrapRef,
+ } = useFitText(album?.name);
+
+ const {
+ setCurrentSongIndex,
+ addToQueue,
+ setQueue,
+ setSongCallback,
+ playAudioSource,
+ togglePlayPause,
+ isPlaying,
+ song: currentSong,
+ } = usePlayer();
+
+ const albumCoverURL = useMemo(
+ () => getLibraryImageUrl(album?.cover_url, getBaseURL),
+ [album?.cover_url],
+ );
+ const artistIconURL = useMemo(
+ () => getLibraryImageUrl(artist?.icon_url, getBaseURL),
+ [artist?.icon_url],
+ );
+
+ const totalDuration = useMemo(() => {
+ return (album?.songs || []).reduce(
+ (acc, song) => acc + (song.duration || 0),
+ 0,
+ );
+ }, [album?.songs]);
+ const addAlbumToPlaylist = useMutation({
+ mutationFn: async ({ id }: { id: number; name: string }) => {
+ if (!album) throw new Error("Album unavailable");
+ await addAlbumTracksToPlaylist(id, album.id);
+ },
+ onSuccess: async (_, playlist) => {
+ toast.success(`Added album to ${playlist.name}`);
+ await queryClient.invalidateQueries({
+ queryKey: ["playlist", playlist.id],
+ refetchType: "none",
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["playlists"],
+ refetchType: "none",
+ });
+ },
+ onError: () => toast("Could not add this album."),
+ });
+
+ const addAlbumToQueue = () => {
+ if (!album || !artist) return;
+ addToQueue(
+ album.songs.map((song) => ({
+ song,
+ album,
+ artist,
+ })),
+ );
+ toast.success(`Added ${album.name} to queue`);
+ };
+
+ const saveAlbum = () => {
+ if (!album) return;
+ const count = downloadAlbum(album);
+ if (count) toast.success(`Downloading ${count} songs from ${album.name}`);
+ };
+
+ const playTrack = (track: LibraryAlbum["songs"][number]) => {
+ if (!album || !artist) return;
+ if (currentSong?.id === track.id) {
+ togglePlayPause();
+ return;
+ }
+
+ const index = album.songs.findIndex((song) => song.id === track.id);
+ setQueue(album.songs.map((song) => ({ song, album, artist })));
+ setCurrentSongIndex(Math.max(index, 0));
+ setSongCallback(track, artist, album);
+ playAudioSource();
+ };
+
+ const playAlbum = () => {
+ const firstSong = album?.songs?.[0];
+ if (!artist || !firstSong) return;
+ playTrack(firstSong);
+ };
+
+ if (!id && !devAlbum) return ;
+ if (albumQuery.isPending) return ;
+ if (albumQuery.isError || !album || !artist)
+ return (
+ void albumQuery.refetch()} />
+ );
+
+ const isAlbumPlaying =
+ isPlaying && currentSong?.album_object?.id === album.id;
+ const titleParts = titleWrapped ? splitQualifiedTitle(album.name) : null;
+
+ return (
+
+
+
+ {albumCoverURL && (
+
+
+
+ )}
+
+
+
+ Album
+
+
+ {titleParts ? (
+ <>
+ {titleParts[0]}
+ {titleParts[1]}
+ >
+ ) : (
+ album.name
+ )}
+
+
+ {artistIconURL && (
+
+
+
+ )}
+
+ {artist.name}
+
+
•
+
+ {album.songs.length} songs, {formatTotalDuration(totalDuration)}
+
+
+
+
+
+
+
+ {isAlbumPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Play
+
+
+
+ Add to queue
+
+
+
+ Download album
+
+
+
+
+ View artist
+
+
+ {session?.role === "admin" && (
+
+ )}
+
+
+
+ Add to playlist
+
+
+ {playlists.isPending && (
+
+
+ Loading playlists…
+
+ )}
+ {playlists.isError && (
+ void playlists.refetch()}>
+
+ Try loading again
+
+ )}
+ {playlists.data?.map((playlist) => (
+
+ addAlbumToPlaylist.mutate({
+ id: playlist.id,
+ name: playlist.name,
+ })
+ }
+ >
+
+ {playlist.name}
+
+ ))}
+ {playlists.isSuccess && (
+ setCreateOpen(true)}>
+
+ New playlist
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/album/album-track-list.tsx b/apps/web/app/(app)/album/album-track-list.tsx
new file mode 100644
index 00000000..635963a0
--- /dev/null
+++ b/apps/web/app/(app)/album/album-track-list.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import type { LibraryAlbum } from "@parson/music-sdk";
+import { Clock, Play } from "lucide-react";
+import SongMenu from "@/features/library/song-menu";
+
+function formatDuration(duration: number) {
+ const minutes = Math.floor(duration / 60);
+ const seconds = Math.round(duration % 60);
+ return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
+}
+
+export function AlbumTrackList({
+ activeSongId,
+ album,
+ artistName,
+ onPlay,
+}: {
+ activeSongId?: string;
+ album: LibraryAlbum;
+ artistName: string;
+ onPlay: (track: LibraryAlbum["songs"][number]) => void;
+}) {
+ return (
+
+
+
+ {album.songs.map((track, index) => {
+ const active = activeSongId === track.id;
+ return (
+
+
+
onPlay(track)}
+ type="button"
+ >
+
+ {track.track_number || index + 1}
+
+
+
+
onPlay(track)}
+ type="button"
+ >
+
+ {track.name}
+
+ {artistName}
+
+
+ {formatDuration(track.duration)}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/app/(app)/album/page.tsx b/apps/web/app/(app)/album/page.tsx
index 58e2d5df..9da4bf43 100644
--- a/apps/web/app/(app)/album/page.tsx
+++ b/apps/web/app/(app)/album/page.tsx
@@ -1,10 +1,17 @@
import { Suspense } from "react";
-import AlbumComponent from "./AlbumComponent";
+import AlbumDetails from "./album-details";
+import type { Metadata } from "next";
+import RouteLoading from "@/components/app/route-loading";
+
+export const metadata: Metadata = {
+ title: "Album",
+ description: "Album details and tracks.",
+};
export default function AlbumPage() {
return (
-
-
+ }>
+
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/apps/web/app/(app)/app-shell.tsx b/apps/web/app/(app)/app-shell.tsx
new file mode 100644
index 00000000..f3269f38
--- /dev/null
+++ b/apps/web/app/(app)/app-shell.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { ArrowRight, Search } from "lucide-react";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import {
+ FormEvent,
+ type KeyboardEvent,
+ type ReactNode,
+ useEffect,
+ useState,
+} from "react";
+import AppSidebar from "@/components/layout/app-sidebar";
+import ParsonBrandMark from "@/components/icons/parson-brand-mark";
+import { DesktopWindowControls } from "@/components/layout/desktop-window-controls";
+import { getLibraryReadiness } from "@parson/music-sdk";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ libraryReadinessPollInterval,
+ libraryReadinessShouldRefetch,
+} from "@/features/library/library-readiness-state";
+
+export default function AppShell({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const queryClient = useQueryClient();
+ const [query, setQuery] = useState("");
+ const readiness = useQuery({
+ queryKey: ["library", "readiness"],
+ queryFn: getLibraryReadiness,
+ refetchInterval: (query) => libraryReadinessPollInterval(query.state.data),
+ refetchOnMount: (query) => libraryReadinessShouldRefetch(query.state.data),
+ refetchOnReconnect: (query) =>
+ libraryReadinessShouldRefetch(query.state.data),
+ refetchOnWindowFocus: (query) =>
+ libraryReadinessShouldRefetch(query.state.data),
+ });
+
+ useEffect(() => {
+ if (!readiness.data) return;
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === "library" && query.queryKey[1] !== "readiness",
+ });
+ void queryClient.invalidateQueries({ queryKey: ["search"] });
+ void queryClient.invalidateQueries({ queryKey: ["albums"] });
+ void queryClient.invalidateQueries({ queryKey: ["artists"] });
+ }, [queryClient, readiness.data?.catalog_revision]);
+
+ useEffect(() => {
+ const syncQueryFromLocation = () => {
+ setQuery(
+ pathname === "/search"
+ ? (new URLSearchParams(window.location.search).get("q") ?? "")
+ : "",
+ );
+ };
+ syncQueryFromLocation();
+ window.addEventListener("popstate", syncQueryFromLocation);
+ return () => window.removeEventListener("popstate", syncQueryFromLocation);
+ }, [pathname]);
+
+ function navigateToSearch() {
+ const value = query.trim();
+ if (!value) return;
+ window.dispatchEvent(new Event("parson:search-submitted"));
+ router.push(`/search?q=${encodeURIComponent(value)}`);
+ }
+
+ function submitSearch(event: FormEvent) {
+ event.preventDefault();
+ navigateToSearch();
+ }
+
+ function submitSearchFromKeyboard(event: KeyboardEvent) {
+ if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
+ event.preventDefault();
+ navigateToSearch();
+ }
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/app/(app)/artist/ArtistComponent.tsx b/apps/web/app/(app)/artist/ArtistComponent.tsx
deleted file mode 100644
index d7b93df7..00000000
--- a/apps/web/app/(app)/artist/ArtistComponent.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-"use client"
-import getBaseURL from "@/lib/Server/getBaseURL";
-
-import SongsInLibrary from "@/components/Artist/SongsInLibrary";
-import Description from "@/components/Description/Description";
-import ScrollButtons from "@/components/Home/ScrollButtons";
-import PageGradient from "@/components/Layout/PageGradient";
-import AlbumCard from "@/components/Music/Card/Album/AlbumCard";
-import BigCard from "@/components/Music/Card/BigCard";
-import { getArtistInfo } from "@music/sdk";
-import { Album, Artist, LibrarySong } from "@music/sdk/types";
-import { useSearchParams } from "next/navigation";
-import { Suspense, useEffect, useState } from "react";
-
-export default function ArtistComponent() {
- const searchParams = useSearchParams();
- const id = searchParams?.get("id");
-
- const [artist, setArtist] = useState(null);
- const [randomSongs, setRandomSongs] = useState([]);
- const [albums, setAlbums] = useState([]);
-
- useEffect(() => {
- if (!id || typeof id !== "string") {
- return;
- }
-
- const fetchData = async () => {
- const artist = await getArtistInfo(id);
-
- if (!artist) {
- return
- }
-
- setArtist(artist);
- setAlbums(artist.albums);
-
- const allSongsData = artist.albums.flatMap((album) =>
- album.songs.map((song) => ({
- ...song,
- artist_object: artist,
- album_object: album,
- album: album.name,
- image: album.cover_url,
- }))
- );
-
- for (let i = allSongsData.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [allSongsData[i] as any, allSongsData[j] as any] = [allSongsData[j], allSongsData[i]];
- }
-
- setRandomSongs(allSongsData.slice(0, 20));
- };
-
- fetchData();
- }, [id]);
-
- function formatFollowers(followers: number): string {
- if (followers >= 1000000) {
- return (followers / 1000000).toFixed(1) + "M";
- } else if (followers >= 1000) {
- return (followers / 1000).toFixed(1) + "K";
- } else {
- return followers.toString();
- }
- }
-
- if (!artist) {
- return
- }
-
- const artistIconURL = artist.icon_url.length === 0 ? "/snf.png" : `${getBaseURL()}/image/${encodeURIComponent(artist.icon_url)}?raw=true`;
-
- return (
- <>
-
-
-
-
{artist.name}
- {formatFollowers(artist.followers)} Followers
-
-
-
-
-
-
-
-
- {randomSongs.map((song, index) => (
-
-
-
- ))}
-
-
-
-
-
- {albums
- .sort((a: Album, b: Album) => {
- const dateA = new Date(a.first_release_date).getTime();
- const dateB = new Date(b.first_release_date).getTime();
-
- if (isNaN(dateA)) return 1;
- if (isNaN(dateB)) return -1;
-
- return dateB - dateA;
- })
- .map((album: Album, index: number) => (
-
- ))}
-
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/web/app/(app)/artist/artist-details.tsx b/apps/web/app/(app)/artist/artist-details.tsx
new file mode 100644
index 00000000..3f182b69
--- /dev/null
+++ b/apps/web/app/(app)/artist/artist-details.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { AlbumArtFallback, getLibraryImageUrl } from "@/lib/images/image-url";
+import getBaseURL from "@/lib/api/server-url";
+import { Album, Artist } from "@parson/music-sdk/types";
+import { useArtist } from "@/features/library/use-library-entity";
+import Image from "next/image";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useMemo } from "react";
+import { usePageTitle } from "@/components/app/title-metadata";
+import EntityPageState from "@/features/library/entity-page-state";
+import AlbumMenu from "@/features/library/album-menu";
+
+type ArtistDetailsProps = {
+ devArtist?: Artist;
+ devAlbums?: Album[];
+};
+
+const sortAlbumsByReleaseDate = (albums: Album[]) =>
+ [...albums].sort((left, right) => {
+ const leftTime = Date.parse(left.first_release_date || "");
+ const rightTime = Date.parse(right.first_release_date || "");
+ const safeLeft = Number.isFinite(leftTime) ? leftTime : -Infinity;
+ const safeRight = Number.isFinite(rightTime) ? rightTime : -Infinity;
+ return safeRight - safeLeft;
+ });
+
+export default function ArtistDetails({
+ devArtist,
+ devAlbums,
+}: ArtistDetailsProps = {}) {
+ const searchParams = useSearchParams();
+ const id = searchParams?.get("id");
+
+ const artistQuery = useArtist(id, devArtist);
+ const artist = artistQuery.data ?? null;
+ usePageTitle(artist?.name);
+ const albums = devAlbums ?? artist?.albums ?? [];
+
+ const artistIconURL = useMemo(
+ () => getLibraryImageUrl(artist?.icon_url, getBaseURL),
+ [artist?.icon_url],
+ );
+
+ const discographySections = useMemo(() => {
+ if (artist?.discography?.length) {
+ return artist.discography.map((section) => ({
+ ...section,
+ albums: sortAlbumsByReleaseDate(section.albums),
+ }));
+ }
+
+ return albums.length
+ ? [
+ {
+ key: "albums",
+ title: "Albums",
+ albums: sortAlbumsByReleaseDate(albums),
+ },
+ ]
+ : [];
+ }, [albums, artist?.discography]);
+ if (!id && !devArtist) return ;
+ if (artistQuery.isPending) return ;
+ if (artistQuery.isError || !artist)
+ return (
+ void artistQuery.refetch()}
+ />
+ );
+
+ return (
+
+
+
+
+
+ {artistIconURL && (
+
+
+
+ )}
+
+ {artist.name}
+
+
+
+
+ {discographySections.length > 0 && (
+
+ {discographySections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.albums.map((album) => (
+
+
+
+ {getLibraryImageUrl(album.cover_url, getBaseURL) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {album.name}
+
+
+ {album.primary_type || "Album"}
+
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/artist/page.tsx b/apps/web/app/(app)/artist/page.tsx
index 62f9784b..3928f97d 100644
--- a/apps/web/app/(app)/artist/page.tsx
+++ b/apps/web/app/(app)/artist/page.tsx
@@ -1,10 +1,17 @@
import { Suspense } from "react";
-import ArtistComponent from "./ArtistComponent";
+import ArtistDetails from "./artist-details";
+import type { Metadata } from "next";
+import RouteLoading from "@/components/app/route-loading";
+
+export const metadata: Metadata = {
+ title: "Artist",
+ description: "Artist details and releases.",
+};
export default function ArtistPage() {
return (
-
-
+ }>
+
- )
+ );
}
diff --git a/apps/web/app/(app)/globals.css b/apps/web/app/(app)/globals.css
deleted file mode 100644
index 934d5711..00000000
--- a/apps/web/app/(app)/globals.css
+++ /dev/null
@@ -1,60 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@layer components {
-
- .slider::-webkit-slider-runnable-track {
- transform: translateX(-6px);
- background-color: gray-300;
- height: 3px;
- border-radius: 2px;
- }
-
- .slider::-moz-range-track {
- transform: translateX(-6px);
- background-color: gray-300;
- height: 3px;
- border-radius: 2px
- }
-
- .slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- appearance: none;
- width: 12px;
- height: 12px;
- opacity: 0;
- border-radius: 50%;
- border: none;
- background-color: white;
- }
-
- .slider::-moz-range-thumb {
- -webkit-appearance: none;
- appearance: none;
- width: 12px;
- height: 12px;
- opacity: 0;
- border-radius: 50%;
- border: none;
- background-color: white;
- }
-
- .slider::-webkit-progress-bar {
- background-color: black;
- height: 4px;
- }
-
- .slider::-moz-range-progress {
- background-color: black;
- height: 4px;
- }
-
- .slider:hover::-webkit-slider-thumb {
- opacity: 1;
- }
- .slider:hover::-moz-range-thumb {
- opacity: 1;
- }
-
-}
\ No newline at end of file
diff --git a/apps/web/app/(app)/history/page.tsx b/apps/web/app/(app)/history/page.tsx
deleted file mode 100644
index ea43294f..00000000
--- a/apps/web/app/(app)/history/page.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-
-import { useState, useEffect } from "react";
-import BigCard from "@/components/Music/Card/BigCard";
-import { getListenHistory, getSongInfo } from "@music/sdk";
-import { ScrollArea } from "@music/ui/components/scroll-area";
-import { LibrarySong } from "@music/sdk/types";
-import getSession from "@/lib/Authentication/JWT/getSession";
-
-export default function HistoryPage() {
- const [listenHistorySongs, setListenHistorySongs] = useState([]);
-
- useEffect(() => {
- const fetchListenHistory = async () => {
- const session = getSession()
-
- if (session) {
- const listenHistoryItems = await getListenHistory(Number(session.sub));
- const songDetailsPromises = listenHistoryItems.reverse().map(item => getSongInfo(item.song_id));
- const songDetails = await Promise.all(songDetailsPromises);
-
- setListenHistorySongs(songDetails);
- }
- };
-
- fetchListenHistory();
- }, []);
-
- return (
- <>
- History
-
-
-
- {listenHistorySongs.map((song, index) => (
-
-
-
- ))}
-
-
- >
- );
-}
diff --git a/apps/web/app/(app)/home/page.tsx b/apps/web/app/(app)/home/page.tsx
deleted file mode 100644
index 46517759..00000000
--- a/apps/web/app/(app)/home/page.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client"
-
-import FromYourLibrary from "@/components/Home/FromYourLibrary";
-import LandingCarousel from "@/components/Home/LandingCarousel";
-import ListenAgain from "@/components/Home/ListenAgain";
-import MusicVideos from "@/components/Home/MusicVideos";
-import RandomSongs from "@/components/Home/RandomSongs";
-import RecommendedAlbums from "@/components/Home/RecommendedAlbums";
-import GenreButtons from "@/components/Layout/GenreButtons";
-
-export default function Home() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
index bc3b9362..0c4d39c1 100644
--- a/apps/web/app/(app)/layout.tsx
+++ b/apps/web/app/(app)/layout.tsx
@@ -1,57 +1,13 @@
-import "@music/ui/globals.css";
-import { Inter as FontSans } from "next/font/google";
-import { Metadata, Viewport } from "next";
-import MainLayout from "./main-layout";
-import SplashScreen from "@/components/Layout/SplashScreen";
-import { cn } from "@music/ui/lib/utils";
+import type { ReactNode } from "react";
+import AppShell from "./app-shell";
+import AppProviders from "@/components/app/providers";
+import PlayerBar from "@/features/player/player-bar";
-export const metadata: Metadata = {
- applicationName: "ParsonLabs Music",
- title: {
- default: "ParsonLabs Music",
- template: "%s | ParsonLabs Music",
- },
- description: "Own your music.",
- manifest: "/manifest.json",
- appleWebApp: {
- capable: true,
- statusBarStyle: "default",
- title: "ParsonLabs Music",
- },
- formatDetection: {
- telephone: false,
- },
- openGraph: {
- type: "website",
- title: {
- default: "ParsonLabs Music",
- template: "%s | ParsonLabs Music",
- },
- description: "Own your music.",
- },
-};
-
-const fontSans = FontSans({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-export const viewport: Viewport = {
- themeColor: "#FFFFFF",
-};
-
-export default async function RootLayout({ children }: any) {
+export default function AppLayout({ children }: { children: ReactNode }) {
return (
-
-
-
-
- {children}
-
-
-
+
+ {children}
+
+
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/app/(app)/library/page.tsx b/apps/web/app/(app)/library/page.tsx
new file mode 100644
index 00000000..7da579dd
--- /dev/null
+++ b/apps/web/app/(app)/library/page.tsx
@@ -0,0 +1,19 @@
+import LibraryOverview from "@/features/library/library-overview";
+import type { Metadata } from "next";
+import RouteLoading from "@/components/app/route-loading";
+import { Suspense } from "react";
+
+export const metadata: Metadata = {
+ title: "Library",
+ description: "Your music library.",
+};
+
+export default function LibraryPage() {
+ return (
+
+ }>
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/main-layout.tsx b/apps/web/app/(app)/main-layout.tsx
deleted file mode 100644
index 203ec9e3..00000000
--- a/apps/web/app/(app)/main-layout.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import "@music/ui/globals.css"
-
-import { cn } from "@music/ui/lib/utils"
-import { Toaster } from "sonner"
-import { Metadata, Viewport } from "next"
-import NavBar from "@/components/Layout/Navbar"
-import Player from "@/components/Music/Player"
-import Providers from "@/components/Providers/Providers"
-import Sidebar from "@/components/Layout/Sidebar"
-import QueuePanel from "@/components/Music/Queue/QueuePanel"
-import LyricsOverlay from "@/components/Lyrics/LyricsOverlay"
-import { Suspense } from "react"
-import FriendActivity from "@/components/Friends/FriendActivity"
-import Playlists from "@/components/Layout/Playlists"
-import { Separator } from "@music/ui/components/separator"
-import TopGradient from "@/components/Layout/TopGradient"
-import AIPanel from "@/components/AI/AIPanel"
-import { Inter } from "next/font/google"
-
-const fontSans = Inter({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-export default function MainLayout({ children }: any) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- }
- >
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx
new file mode 100644
index 00000000..01f3369b
--- /dev/null
+++ b/apps/web/app/(app)/page.tsx
@@ -0,0 +1,11 @@
+import LibraryFeed from "@/features/library/library-feed";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Home",
+ description: "Recently played music and recommendations.",
+};
+
+export default function HomePage() {
+ return ;
+}
diff --git a/apps/web/app/(app)/playlist/PlaylistComponent.tsx b/apps/web/app/(app)/playlist/PlaylistComponent.tsx
deleted file mode 100644
index 55bc2bf3..00000000
--- a/apps/web/app/(app)/playlist/PlaylistComponent.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-"use client"
-
-import React, { useState, useEffect } from 'react';
-import PlaylistTable from "@/components/Music/Playlist/PlaylistTable";
-import { Avatar, AvatarFallback } from "@music/ui/components/avatar";
-import DeletePlaylistButton from "@/components/Music/Playlist/DeletePlaylistButton";
-import { ScrollArea, ScrollBar } from "@music/ui/components/scroll-area";
-import { getPlaylist, getSongInfo, getUserInfoById } from "@music/sdk";
-import { useSearchParams } from 'next/navigation';
-import { Album, Artist, LibrarySong, Playlist as OriginalPlaylist } from "@music/sdk/types";
-
-interface Playlist extends OriginalPlaylist {
- users: { id: number; username: string }[];
- createdAt: Date;
- updatedAt: Date;
-}
-
-export default function PlaylistComponent() {
- const searchParams = useSearchParams();
- const id = searchParams?.get("id");
-
- const [playlist, setPlaylist] = useState(null);
- const [songsWithMetadata, setSongsWithMetadata] = useState<(LibrarySong & { date_added: string })[]>([]);
- const [totalDuration, setTotalDuration] = useState(0);
-
- useEffect(() => {
- async function fetchPlaylistData() {
- if (id) {
- const playlistData = await getPlaylist(Number(id));
- const users = await Promise.all(
- playlistData.user_ids.map(async (userId: number) => {
- const userInfo = await getUserInfoById(userId);
- return { id: userInfo.id, username: userInfo.username };
- })
- );
- setPlaylist({
- ...playlistData,
- users,
- createdAt: new Date(playlistData.created_at),
- updatedAt: new Date(playlistData.updated_at)
- });
-
- const songsWithMetadataPromises = playlistData.song_infos.map(async (songInfo: { song_id: string, date_added: string }) => {
- const songData = await getSongInfo(songInfo.song_id);
- return { ...songData, date_added: songInfo.date_added };
- });
-
- const songsWithMetadataData = await Promise.all(songsWithMetadataPromises);
- setSongsWithMetadata(songsWithMetadataData);
-
- const totalDuration = songsWithMetadataData.reduce((total: number, song: LibrarySong & { date_added: string }) => total + song.duration, 0);
- setTotalDuration(totalDuration);
- }
- }
-
- fetchPlaylistData();
- }, [id]);
-
- function formatDuration(duration: number) {
- const hours = Math.floor(duration / 3600);
- const minutes = Math.floor((duration % 3600) / 60);
- const seconds = Math.round(duration % 60);
-
- let result = '';
- if (hours > 0) {
- result += `${hours} Hour${hours > 1 ? 's' : ''} `;
- }
- if (minutes > 0) {
- result += `${minutes} Minute${minutes > 1 ? 's' : ''} `;
- }
- if (seconds > 0) {
- result += `${seconds} Second${seconds > 1 ? 's' : ''}`;
- }
- return result.trim();
- }
-
- if (!playlist) return null
-
-
- return (
-
-
-
-
-
- {playlist.users[0]?.username?.substring(0, 2).toUpperCase()}
-
-
-
-
{playlist.name}
- {playlist.users.map((user) => {
- return Created by {user.username} ;
- })}
- {formatDuration(totalDuration)}
-
-
-
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(app)/playlist/page.tsx b/apps/web/app/(app)/playlist/page.tsx
index cad8adf7..fd6c785a 100644
--- a/apps/web/app/(app)/playlist/page.tsx
+++ b/apps/web/app/(app)/playlist/page.tsx
@@ -1,10 +1,17 @@
-import PlaylistComponent from "./PlaylistComponent";
+import type { Metadata } from "next";
+import PlaylistDetails from "./playlist-details";
+import RouteLoading from "@/components/app/route-loading";
import { Suspense } from "react";
+export const metadata: Metadata = {
+ title: "Playlist",
+ description: "Play and manage a playlist.",
+};
+
export default function PlaylistPage() {
return (
-
-
+ }>
+
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/apps/web/app/(app)/playlist/playlist-components.tsx b/apps/web/app/(app)/playlist/playlist-components.tsx
new file mode 100644
index 00000000..0acc6c8b
--- /dev/null
+++ b/apps/web/app/(app)/playlist/playlist-components.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import SongMenu from "@/features/library/song-menu";
+import getBaseURL from "@/lib/api/server-url";
+import { defaultCover } from "@/lib/images/default-cover";
+import type { LibrarySong } from "@parson/music-sdk/types";
+import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import type { FormEvent } from "react";
+
+export function PlaylistActions({
+ deleteOpen,
+ deletePending,
+ editDescription,
+ editName,
+ editOpen,
+ editPending,
+ hasTracks,
+ name,
+ onDelete,
+ onDeleteOpenChange,
+ onEditDescriptionChange,
+ onEditNameChange,
+ onEditOpenChange,
+ onOpenEdit,
+ onPlay,
+ onSubmitEdit,
+}: {
+ deleteOpen: boolean;
+ deletePending: boolean;
+ editDescription: string;
+ editName: string;
+ editOpen: boolean;
+ editPending: boolean;
+ hasTracks: boolean;
+ name: string;
+ onDelete: () => void;
+ onDeleteOpenChange: (open: boolean) => void;
+ onEditDescriptionChange: (value: string) => void;
+ onEditNameChange: (value: string) => void;
+ onEditOpenChange: (open: boolean) => void;
+ onOpenEdit: () => void;
+ onPlay: () => void;
+ onSubmitEdit: (event: FormEvent) => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit playlist
+
+ onDeleteOpenChange(true)}>
+
+ Delete playlist
+
+
+
+
+
+
+ Edit playlist
+
+
+
+
+
+
+
+ Delete this playlist?
+
+
+
+
+ {deletePending ? "Deleting…" : "Delete playlist"}
+
+
+
+
+
+ );
+}
+
+export function PlaylistTracks({
+ onPlay,
+ onRemove,
+ tracks,
+}: {
+ onPlay: (index: number) => void;
+ onRemove: (songId: string) => void;
+ tracks: LibrarySong[];
+}) {
+ return (
+
+ {!tracks.length && (
+
+
This playlist is empty
+
+ Right-click or long-press any song, then choose Add to playlist.
+
+
+ Browse songs
+
+
+ )}
+ {tracks.map((song, index) => (
+
onRemove(song.id)}
+ song_id={song.id}
+ song_name={song.name}
+ >
+
+
onPlay(index)}
+ type="button"
+ />
+
+ {index + 1}
+
+
+
+
+ {song.name}
+
+
+ {song.artist_object.name}
+
+
+
+ {song.album_object.name}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/(app)/playlist/playlist-details.tsx b/apps/web/app/(app)/playlist/playlist-details.tsx
new file mode 100644
index 00000000..ebd871ce
--- /dev/null
+++ b/apps/web/app/(app)/playlist/playlist-details.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import PlaylistCover from "@/features/library/playlist-cover";
+import { usePlayer } from "@/features/player/player-context";
+import {
+ deletePlaylist,
+ getPlaylist,
+ updatePlaylist,
+ removeSongFromPlaylist,
+ type PlaylistResponse,
+ type PlaylistSummary,
+} from "@parson/music-sdk";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useState, type FormEvent } from "react";
+import { toast } from "sonner";
+import { PlaylistActions, PlaylistTracks } from "./playlist-components";
+
+export default function PlaylistDetails() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const id = Number(searchParams.get("id"));
+ const validId = Number.isSafeInteger(id) && id > 0;
+ const playlist = useQuery({
+ queryKey: ["playlist", id],
+ queryFn: () => getPlaylist(id),
+ enabled: validId,
+ });
+ const songIds = useMemo(
+ () => playlist.data?.song_infos.map((item) => item.song_id) ?? [],
+ [playlist.data?.song_infos],
+ );
+ const remove = useMutation({
+ mutationFn: (songId: string) => removeSongFromPlaylist(id, songId),
+ onSuccess: (_result, songId) => {
+ const currentPlaylist = queryClient.getQueryData([
+ "playlist",
+ id,
+ ]);
+ const removedSong = currentPlaylist?.songs.find(
+ (song) => song.id === songId,
+ );
+ const remainingSongs = currentPlaylist?.songs.filter(
+ (song) => song.id !== songId,
+ );
+ queryClient.setQueryData(
+ ["playlist", id],
+ (current) => {
+ if (!current) return current;
+ const songs = current.songs.filter((song) => song.id !== songId);
+ return {
+ ...current,
+ songs,
+ song_infos: current.song_infos.filter(
+ (item) => item.song_id !== songId,
+ ),
+ song_count: songs.length,
+ total_duration: songs.reduce(
+ (total, song) => total + (song.duration || 0),
+ 0,
+ ),
+ cover_songs: songs.slice(0, 4),
+ };
+ },
+ );
+ queryClient.setQueryData(["playlists"], (current) =>
+ current?.map((item) =>
+ item.id === id
+ ? {
+ ...item,
+ song_count: Math.max(0, item.song_count - 1),
+ total_duration: Math.max(
+ 0,
+ item.total_duration - (removedSong?.duration || 0),
+ ),
+ cover_songs: remainingSongs?.slice(0, 4) ?? item.cover_songs,
+ }
+ : item,
+ ),
+ );
+ toast.success("Removed from playlist");
+ },
+ onError: () => toast("Could not remove this song."),
+ });
+ const removePlaylist = useMutation({
+ mutationFn: () => deletePlaylist(id),
+ onSuccess: () => {
+ queryClient.setQueryData(["playlists"], (current) =>
+ current?.filter((item) => item.id !== id),
+ );
+ queryClient.removeQueries({ queryKey: ["playlist", id], exact: true });
+ toast.success("Playlist deleted");
+ router.replace("/library?view=playlists");
+ },
+ onError: () => toast("Could not delete the playlist."),
+ });
+ const player = usePlayer();
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const editPlaylist = useMutation({
+ mutationFn: () =>
+ updatePlaylist(id, {
+ name: editName.trim(),
+ description: editDescription.trim(),
+ }),
+ onSuccess: () => {
+ const name = editName.trim();
+ const description = editDescription.trim();
+ queryClient.setQueryData(["playlist", id], (current) =>
+ current ? { ...current, name, description } : current,
+ );
+ queryClient.setQueryData(["playlists"], (current) =>
+ current?.map((item) =>
+ item.id === id ? { ...item, name, description } : item,
+ ),
+ );
+ setEditOpen(false);
+ toast.success("Playlist updated");
+ },
+ onError: () => toast("Could not update the playlist."),
+ });
+ const submitEdit = (event: FormEvent) => {
+ event.preventDefault();
+ if (editName.trim()) editPlaylist.mutate();
+ };
+
+ const playFrom = (index: number) => {
+ const queue = playlist.data?.songs ?? [];
+ const selected = queue[index];
+ if (!selected) return;
+ player.setQueue(
+ queue.map((song) => ({
+ song,
+ artist: song.artist_object,
+ album: song.album_object,
+ })),
+ );
+ player.setCurrentSongIndex(index);
+ player.setSongCallback(
+ selected,
+ selected.artist_object,
+ selected.album_object,
+ );
+ player.playAudioSource();
+ };
+
+ if (!validId)
+ return (
+
+ );
+ if (playlist.isPending)
+ return (
+
+
+ Loading playlist…
+
+
+ );
+ if (playlist.isError || !playlist.data)
+ return (
+ void playlist.refetch()}
+ />
+ );
+
+ const tracks = playlist.data.songs;
+ const totalDuration = tracks.reduce(
+ (sum, song) => sum + (song.duration || 0),
+ 0,
+ );
+ const openEdit = () => {
+ setEditName(playlist.data.name);
+ setEditDescription(playlist.data.description ?? "");
+ setEditOpen(true);
+ };
+ return (
+
+
+ Playlists
+
+
+
+ 0}
+ name={playlist.data.name}
+ onDelete={() => removePlaylist.mutate()}
+ onDeleteOpenChange={setDeleteOpen}
+ onEditDescriptionChange={setEditDescription}
+ onEditNameChange={setEditName}
+ onEditOpenChange={setEditOpen}
+ onOpenEdit={openEdit}
+ onPlay={() => playFrom(0)}
+ onSubmitEdit={submitEdit}
+ />
+ remove.mutate(songId)}
+ tracks={tracks}
+ />
+
+ );
+}
+
+function formatTotalDuration(seconds: number) {
+ if (!Number.isFinite(seconds) || seconds <= 0) return "0 min";
+ const wholeSeconds = Math.round(seconds);
+ const hours = Math.floor(wholeSeconds / 3600);
+ const minutes = Math.floor((wholeSeconds % 3600) / 60);
+ if (hours > 0) return `${hours} hr ${minutes} min`;
+ const remainder = wholeSeconds % 60;
+ return `${minutes}:${remainder.toString().padStart(2, "0")}`;
+}
+
+function PlaylistMessage({
+ body,
+ onRetry,
+ title,
+}: {
+ body: string;
+ onRetry?: () => void;
+ title: string;
+}) {
+ return (
+
+
+
{title}
+
{body}
+
+ {onRetry && (
+
+ Try again
+
+ )}
+
+ Back to playlists
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/profile/UsernameComponent.tsx b/apps/web/app/(app)/profile/UsernameComponent.tsx
deleted file mode 100644
index ef945490..00000000
--- a/apps/web/app/(app)/profile/UsernameComponent.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import Image from "next/image";
-import { Avatar, AvatarFallback } from "@music/ui/components/avatar";
-import FollowButton from "@/components/Friends/FollowButton";
-import { getCookie } from "cookies-next";
-import { getListenHistory, getUserInfo, getSongInfo, getAlbumInfo, LibraryAlbum } from "@music/sdk";
-import { useRouter } from "next/router";
-import getSession, { ExtendedJWTPayload } from "@/lib/Authentication/JWT/getSession";
-import { useSearchParams } from "next/navigation";
-import { Artist, LibrarySong } from "@music/sdk/types";
-import ScrollButtons from "@/components/Home/ScrollButtons";
-import BigCard from "@/components/Music/Card/BigCard";
-import getBaseURL from "@/lib/Server/getBaseURL";
-import AlbumCard from "@/components/Music/Card/Album/AlbumCard";
-import ArtistCard from "@/components/Music/Artist/ArtistCard";
-import setCache, { getCache } from "@/lib/Caching/cache";
-
-export default function UsernameComponent() {
- const searchParams = useSearchParams();
- const username = searchParams?.get("username");
-
- const [user, setUser] = useState(null);
- const [topArtists, setTopArtists] = useState<(Artist & { count: number })[]>([]);
- const [topAlbums, setTopAlbums] = useState<(LibraryAlbum & { count: number, artist: Artist })[]>([]);
- const [topSongs, setTopSongs] = useState<(LibrarySong & { count: number })[]>([]);
-
- const session = getSession();
- useEffect(() => {
- async function fetchData() {
- const userCacheKey = `userInfo_${username}`;
- const listenHistoryCacheKey = `listenHistory_${username}`;
- const songInfoCacheKey = `songInfo_${username}`;
-
- let userInfo = getCache(userCacheKey);
- if (!userInfo) {
- userInfo = await getUserInfo(username as string);
- setCache(userCacheKey, userInfo, 3600000);
- }
- setUser(userInfo);
-
- let listenHistory = getCache(listenHistoryCacheKey);
- if (!listenHistory) {
- listenHistory = await getListenHistory(userInfo.id);
- setCache(listenHistoryCacheKey, listenHistory, 3600000);
- }
-
- const songInfoPromises = listenHistory.map(async (history: { song_id: string; }) => {
- const songCacheKey = `${songInfoCacheKey}_${history.song_id}`;
- let songInfo = getCache(songCacheKey);
- if (!songInfo) {
- songInfo = await getSongInfo(history.song_id);
- setCache(songCacheKey, songInfo, 3600000);
- }
- return songInfo;
- });
- const songsInfo = await Promise.all(songInfoPromises);
-
- const artistFrequency: Record = songsInfo.reduce((acc, song) => {
- const artistId = song.artist_object.id;
- if (!acc[artistId]) {
- acc[artistId] = { count: 0, info: song.artist_object };
- }
- acc[artistId].count++;
- return acc;
- }, {});
-
- const albumFrequency: Record = songsInfo.reduce((acc, song) => {
- const albumId = song.album_object.id;
- if (!acc[albumId]) {
- acc[albumId] = { count: 0, info: song.album_object, artist: song.artist_object };
- }
- acc[albumId].count++;
- return acc;
- }, {});
-
- const songFrequency: Record = songsInfo.reduce((acc, song) => {
- const songId = song.id;
- if (!acc[songId]) {
- acc[songId] = { count: 0, info: song };
- }
- acc[songId].count++;
- return acc;
- }, {});
-
-
- const sortedArtists = Object.values(artistFrequency)
- .sort((a, b) => b.count - a.count)
- .map((artist) => ({ ...artist.info, count: artist.count }));
-
- const sortedAlbums = Object.values(albumFrequency)
- .sort((a, b) => b.count - a.count)
- .map((album) => ({ ...album.info, artist: album.artist, count: album.count }));
-
- const sortedSongs = Object.values(songFrequency)
- .sort((a, b) => b.count - a.count)
- .map((song) => ({ ...song.info, count: song.count }));
-
- setTopArtists(sortedArtists);
- setTopAlbums(sortedAlbums);
- setTopSongs(sortedSongs);
- }
-
- if (username) {
- fetchData();
- }
- }, [username]);
-
- if (!user) {
- return null;
- }
-
- return (
-
-
- {user.image ? (
-
- ) : (
-
-
- {user.username.substring(0, 2).toUpperCase()}
-
-
- )}
-
-
@{user.username}
- {user.username !== session?.username && (
-
- )}
-
-
-
-
-
- {topArtists.map((artist, index) => (
-
- ))}
-
-
-
-
-
- {topAlbums.map((album, index) => (
-
- ))}
-
-
-
-
-
- {topSongs.map((song, index) => (
-
-
-
- ))}
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(app)/profile/page.tsx b/apps/web/app/(app)/profile/page.tsx
index 10ace32b..cff63eed 100644
--- a/apps/web/app/(app)/profile/page.tsx
+++ b/apps/web/app/(app)/profile/page.tsx
@@ -1,10 +1,5 @@
-import { Suspense } from "react";
-import UsernameComponent from "./UsernameComponent";
+import { redirect } from "next/navigation";
export default function ProfilePage() {
- return (
-
-
-
- )
-}
\ No newline at end of file
+ redirect("/");
+}
diff --git a/apps/web/app/(app)/search/SearchComponent.tsx b/apps/web/app/(app)/search/SearchComponent.tsx
deleted file mode 100644
index 9460ab24..00000000
--- a/apps/web/app/(app)/search/SearchComponent.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import Link from "next/link";
-import HorizontalCard from "@/components/Music/Card/HorizontalCard";
-import { useSearchParams } from 'next/navigation'
-import { searchLibrary } from "@music/sdk";
-import TopResultsCard from "@/components/Music/Card/Search/TopResultsCard";
-import { SearchIcon } from "lucide-react";
-
-export default function SearchComponent() {
- const searchParams = useSearchParams()
- const query = searchParams?.get("q") ?? ""
-
- const [results, setResults] = useState([]);
- const [suggestion, setSuggestion] = useState(null);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- async function getSearchResults() {
- setLoading(true);
- const searchResults = await searchLibrary(query);
- setResults(searchResults);
- setLoading(false);
- }
-
- getSearchResults();
- }, [query]);
-
- return (
-
-
- {suggestion && suggestion.toLowerCase() !== query.toLowerCase() && (
-
- Did you mean:{" "}
-
- {suggestion}
-
-
- )}
-
- { !loading && !results &&
Top Result
}
-
-
- {loading ? (
-
-
-
- ) : (
- results && results[0] &&
- )}
-
-
- {loading ? (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
- results && results[0] && (
- <>
- {results[0].item_type === "album" && (
- <>
- {results.slice(1).some((result: any) => result.item_type === "song") && (
- <>
-
Songs
-
- {results.slice(1).filter((result: any) => result.item_type === "song").map((result: any) => (
-
- ))}
-
- >
- )}
- {results.slice(1).some((result: any) => result.item_type === "album") && (
- <>
-
Albums
-
- {results.slice(1).filter((result: any) => result.item_type === "album").map((result: any) => (
-
- ))}
-
- >
- )}
- {results.slice(1).some((result: any) => result.item_type === "artist") && (
- <>
-
Artists
-
- {results.slice(1).filter((result: any) => result.item_type === "artist").map((result: any) => (
-
- ))}
-
- >
- )}
- >
- )}
- {results[0].item_type === "artist" && (
- <>
- {results.slice(1).some((result: any) => result.item_type === "album") && (
- <>
-
Albums
-
- {results.slice(1).filter((result: any) => result.item_type === "album").map((result: any) => (
-
- ))}
-
- >
- )}
- {results.slice(1).some((result: any) => result.item_type === "song") && (
- <>
-
Songs
-
- {results.slice(1).filter((result: any) => result.item_type === "song").map((result: any) => (
-
- ))}
-
- >
- )}
- {results.slice(1).some((result: any) => result.item_type === "artist") && (
- <>
-
Artists
-
- {results.slice(1).filter((result: any) => result.item_type === "artist").map((result: any) => (
-
- ))}
-
- >
- )}
- >
- )}
- {results[0].item_type === "song" && (
- <>
- {results.slice(1).some((result: any) => result.item_type === "song") && (
- <>
-
Songs
-
- {results.slice(1).filter((result: any) => result.item_type === "song").map((result: any) => (
-
- ))}
-
- >
- )}
- {results.slice(1).some((result: any) => result.item_type === "album") && (
- <>
-
Albums
-
- {results.slice(1).filter((result: any) => result.item_type === "album").map((result: any) => (
-
- ))}
-
- >
- )}
- {results.slice(1).some((result: any) => result.item_type === "artist") && (
- <>
-
Artists
-
- {results.slice(1).filter((result: any) => result.item_type === "artist").map((result: any) => (
-
- ))}
-
- >
- )}
- >
- )}
- >
- )
- )}
-
-
- );
-}
-
-function HorizontalCardSkeleton() {
- return (
-
- );
-}
-
-function TopResultCardSkeleton() {
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(app)/search/page.tsx b/apps/web/app/(app)/search/page.tsx
index 95398f4d..c54f3fb3 100644
--- a/apps/web/app/(app)/search/page.tsx
+++ b/apps/web/app/(app)/search/page.tsx
@@ -1,10 +1,17 @@
import { Suspense } from "react";
-import SearchComponent from "./SearchComponent";
+import SearchResults from "./search-results";
+import type { Metadata } from "next";
+import RouteLoading from "@/components/app/route-loading";
+
+export const metadata: Metadata = {
+ title: "Search",
+ description: "Search your music library.",
+};
export default function SearchPage() {
return (
-
-
+ }>
+
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/apps/web/app/(app)/search/search-results.tsx b/apps/web/app/(app)/search/search-results.tsx
new file mode 100644
index 00000000..66660e44
--- /dev/null
+++ b/apps/web/app/(app)/search/search-results.tsx
@@ -0,0 +1,319 @@
+"use client";
+
+import { usePlayer } from "@/features/player/player-context";
+import getBaseURL from "@/lib/api/server-url";
+import { AlbumArtFallback, getLibraryImageUrl } from "@/lib/images/image-url";
+import {
+ getAlbumInfo,
+ searchLibrary,
+ type LibraryAlbum,
+} from "@parson/music-sdk";
+import type { CombinedItem } from "@parson/music-sdk/types";
+import { useQuery } from "@tanstack/react-query";
+import { Loader2, Play, Search } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useState, type ReactNode } from "react";
+import AlbumMenu from "@/features/library/album-menu";
+import ArtistMenu from "@/features/library/artist-menu";
+import SongMenu from "@/features/library/song-menu";
+import { toast } from "sonner";
+
+const isPlayable = (item: CombinedItem) =>
+ item.item_type === "song" || item.item_type === "album";
+
+const hrefFor = (item: CombinedItem) =>
+ item.item_type === "song"
+ ? `/album?id=${item.album_object?.id ?? ""}`
+ : `/${item.item_type}?id=${item.id}`;
+
+function artworkFor(item: CombinedItem) {
+ const image =
+ item.item_type === "artist"
+ ? item.artist_object?.icon_url
+ : item.album_object?.cover_url;
+ return getLibraryImageUrl(image, getBaseURL);
+}
+
+function SearchArtwork({ item }: { item: CombinedItem }) {
+ const artwork = artworkFor(item);
+ if (item.item_type === "artist" && !artwork) return null;
+
+ const artworkContent = (
+ <>
+ {artwork ? (
+
+ ) : (
+
+ )}
+ >
+ );
+ const className = `relative h-14 w-14 shrink-0 overflow-hidden bg-white/[0.04] ${
+ item.item_type === "artist" ? "rounded-full" : "rounded-md"
+ }`;
+
+ if (isPlayable(item)) {
+ return {artworkContent}
;
+ }
+
+ return (
+
+ {artworkContent}
+
+ );
+}
+
+function SearchResultMenu({
+ children,
+ item,
+}: {
+ children: ReactNode;
+ item: CombinedItem;
+}) {
+ if (item.item_type === "song") {
+ return (
+
+ {children}
+
+ );
+ }
+ if (item.item_type === "album") {
+ return (
+
+ {children}
+
+ );
+ }
+ if (item.item_type === "artist") {
+ return {children} ;
+ }
+ return children;
+}
+
+export default function SearchResults() {
+ const router = useRouter();
+ const query = useSearchParams().get("q") ?? "";
+ const searchTerm = query.trim();
+ const search = useQuery({
+ queryKey: ["search", searchTerm],
+ queryFn: () => searchLibrary(searchTerm),
+ enabled: Boolean(searchTerm),
+ });
+ const results = search.data ?? [];
+ const uniqueResults = Array.from(
+ new Map(
+ results.map((item) => [`${item.item_type}-${item.id}`, item] as const),
+ ).values(),
+ );
+ const { setCurrentSongIndex, setQueue, setSongCallback, playAudioSource } =
+ usePlayer();
+ const [playingKey, setPlayingKey] = useState(null);
+
+ useEffect(() => {
+ const id = "search-error";
+ if (search.isError) {
+ toast("Search could not reach the server.", {
+ action: {
+ label: "Try again",
+ onClick: () => void search.refetch(),
+ },
+ id,
+ });
+ } else {
+ toast.dismiss(id);
+ }
+ }, [search.isError, search.refetch]);
+
+ const play = async (item: CombinedItem) => {
+ if (!isPlayable(item)) return;
+ const key = `${item.item_type}-${item.id}`;
+ if (playingKey === key) return;
+ setPlayingKey(key);
+ try {
+ if (item.item_type === "album") {
+ const album = (await getAlbumInfo(item.id, false)) as LibraryAlbum;
+ const firstSong = album.songs[0];
+ const artist = album.artist_object;
+ if (!firstSong || !artist) {
+ toast("This album has no playable songs.");
+ return;
+ }
+ setQueue(album.songs.map((song) => ({ song, artist, album })));
+ setCurrentSongIndex(0);
+ setSongCallback(firstSong, artist, album);
+ playAudioSource();
+ return;
+ }
+
+ const artist = item.artist_object ?? { id: "", name: "Unknown artist" };
+ const album = item.album_object ?? { id: "", name: "Unknown album" };
+ const track = {
+ id: item.id,
+ name: item.name,
+ artist: artist.name,
+ duration: item.song_object?.duration ?? 0,
+ };
+ setQueue([{ song: track, artist, album }]);
+ setCurrentSongIndex(0);
+ setSongCallback(track, artist, album);
+ playAudioSource();
+ } catch {
+ toast(
+ item.item_type === "album"
+ ? "That album could not be played. Try again."
+ : "That song could not be played. Try again.",
+ );
+ } finally {
+ setPlayingKey(null);
+ }
+ };
+
+ return (
+
+ {searchTerm && (
+
+ Results for "{query}"
+
+ )}
+ {!searchTerm && (
+
+
+
+
+ Find anything in your library
+
+
+
+ )}
+ {search.isFetching && (
+
+ Searching…
+
+ )}
+ {!search.isFetching &&
+ !search.isError &&
+ searchTerm &&
+ uniqueResults.length === 0 && (
+
+
+ No matches for “{searchTerm}”
+
+
+ )}
+
+ {uniqueResults.map((item) => {
+ const playable = isPlayable(item);
+ const playsOnRowClick = item.item_type === "song";
+ const opensOnRowClick = item.item_type === "album";
+ const key = `${item.item_type}-${item.id}`;
+ const activateRow = () => {
+ if (playsOnRowClick) void play(item);
+ else if (opensOnRowClick) router.push(hrefFor(item));
+ };
+ return (
+
+ {
+ if (event.target !== event.currentTarget) return;
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ activateRow();
+ }
+ }
+ : undefined
+ }
+ role={playsOnRowClick || opensOnRowClick ? "button" : undefined}
+ tabIndex={playsOnRowClick || opensOnRowClick ? 0 : undefined}
+ >
+
+
+ {playable ? (
+
+ {item.name}
+
+ ) : (
+
+ {item.name}
+
+ )}
+
+ {item.item_type}
+ {item.artist_object?.name && (
+ <>
+ {" - "}
+ {playable ? (
+
+ {item.artist_object.name}
+
+ ) : (
+
+ {item.artist_object.name}
+
+ )}
+ >
+ )}
+
+
+ {playable && (
+ {
+ event.stopPropagation();
+ void play(item);
+ }}
+ type="button"
+ >
+ {playingKey === key ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx
new file mode 100644
index 00000000..ac5453fd
--- /dev/null
+++ b/apps/web/app/(app)/settings/page.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import BitrateForm from "@/features/settings/bitrate-form";
+import FileBrowser from "@/features/setup/file-browser";
+import AccountSettings from "@/features/settings/account-settings";
+import UserForm from "@/features/settings/user-form";
+import { useSession } from "@/features/account/session-provider";
+import { getSetupStatus } from "@parson/music-sdk";
+import { useQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useState } from "react";
+import ServerConnectionPanel from "@/features/server/server-connection-panel";
+
+const settingsTabs = [
+ "account",
+ "playback",
+ "server",
+ "library",
+ "users",
+] as const;
+type SettingsTab = (typeof settingsTabs)[number];
+
+const labels: Record = {
+ account: "Account",
+ playback: "Playback",
+ server: "Server",
+ library: "Library",
+ users: "Users",
+};
+
+export default function SettingsPage() {
+ const { session } = useSession();
+ const isAdmin = session?.role === "admin";
+ const [activeTab, setActiveTab] = useState("account");
+ const availableTabs = isAdmin ? settingsTabs : settingsTabs.slice(0, 3);
+ const setup = useQuery({
+ queryKey: ["setup-status", "settings-library"],
+ queryFn: getSetupStatus,
+ enabled: Boolean(isAdmin && activeTab === "library"),
+ });
+
+ return (
+
+
Settings
+
+
+ {availableTabs.map((tab) => (
+ setActiveTab(tab)}
+ role="tab"
+ type="button"
+ >
+ {labels[tab]}
+ {activeTab === tab && (
+
+ )}
+
+ ))}
+
+
+
+ {activeTab === "account" &&
}
+ {activeTab === "playback" && (
+
+ )}
+ {activeTab === "server" &&
}
+ {isAdmin &&
+ activeTab === "library" &&
+ (setup.data ? (
+
+ ) : (
+
+
+
+ ))}
+ {isAdmin && activeTab === "users" &&
}
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
deleted file mode 100644
index d9a51730..00000000
--- a/apps/web/app/(dashboard)/layout.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-"use client"
-
-import "@music/ui/globals.css"
-import { Inter as FontSans } from "next/font/google"
-
-import pl from "@/assets/pl-tp.png"
-import SettingsSidebar from "@/components/Layout/Settings/Sidebar"
-import { cn } from "@music/ui/lib/utils"
-import Link from "next/link"
-import Image from "next/image"
-import { usePathname, useRouter } from "next/navigation"
-import { useEffect } from "react"
-import getSession from "@/lib/Authentication/JWT/getSession"
-
-const fontSans = FontSans({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-type RootLayoutProps = {
- children: React.ReactNode
-}
-
-export default function RootLayout({ children }: RootLayoutProps) {
- const session = getSession()
- const isAdmin = session?.role === "admin"
- const pathname = usePathname()
- const router = useRouter()
-
-
- useEffect(() => {
- const adminPaths = ["/settings/users/", "/settings/server/", "/settings/library/"]
-
- if (adminPaths.includes(pathname) && !isAdmin) {
- router.push("/settings")
- }
- }, [pathname, isAdmin, router])
-
- return (
-
-
-
-
-
-
-
- ParsonLabs Music
-
-
-
-
-
-
- {children}
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/app/(dashboard)/settings/library/page.tsx b/apps/web/app/(dashboard)/settings/library/page.tsx
deleted file mode 100644
index c38a39b8..00000000
--- a/apps/web/app/(dashboard)/settings/library/page.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";;
-
-import React, { useState, useEffect } from "react";
-import useWebSocket from "react-use-websocket";
-import { ScrollArea, ScrollBar } from "@music/ui/components/scroll-area";
-import FileBrowser from "@/components/FileBrowser/FileBrowser";
-
-export default function SetupLibrary() {
- const [messageHistory, setMessageHistory] = useState[]>([]);
-
- const baseUrl = getBaseURL()?.replace(/^https?:\/\//, '');
- const socketUrl = `ws://${baseUrl ?? window.location.hostname}/ws`;
- const { lastMessage } = useWebSocket(socketUrl);
-
- useEffect(() => {
- if (lastMessage !== null && typeof lastMessage.data === 'string') {
- const newMessage = new MessageEvent("message", { data: lastMessage.data });
- setMessageHistory((prev) => prev.concat(newMessage));
- } else {
- console.error("Unsupported message data type:", typeof lastMessage?.data);
- }
- }, [lastMessage]);
-
- return (
-
-
-
-
-
-
-
Logs
-
-
-
- {messageHistory.map((message) => (
- {message.data}
- ))}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx
deleted file mode 100644
index dd90116a..00000000
--- a/apps/web/app/(dashboard)/settings/page.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-"use client"
-
-import ChangeBitrate from "@/components/User/ChangeBitrate"
-import { ChangePassword } from "@/components/User/ChangePassword"
-import getSession from "@/lib/Authentication/JWT/getSession"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { uploadProfilePicture, getProfilePicture } from "@music/sdk"
-import { Button } from "@music/ui/components/button"
-import Image from "next/image"
-import {
- Form,
- FormControl,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form"
-import { Input } from "@music/ui/components/input"
-import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"
-import { useState, useEffect } from "react"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-const FormSchema = z.object({
- picture: z.instanceof(File).optional(),
-})
-
-export default function SettingsPage() {
- const form = useForm>({
- resolver: zodResolver(FormSchema),
- defaultValues: {
- picture: undefined,
- },
- })
-
- const [file, setFile] = useState(null)
- const [message, setMessage] = useState(null)
- const [profilePicture, setProfilePicture] = useState(null)
- const [username, setUsername] = useState("")
-
- useEffect(() => {
- const fetchSessionAndProfilePicture = async () => {
- const session = getSession()
- if (session) {
- setUsername(session.username)
- const profilePic = await getProfilePicture(Number(session.sub))
- if (profilePic) {
- setProfilePicture(URL.createObjectURL(profilePic))
- } else {
- setProfilePicture(null)
- }
- }
- }
- fetchSessionAndProfilePicture()
- }, [])
-
- async function onSubmit(data: z.infer) {
- const session = getSession()
- const userId = Number(session?.sub)
-
- if (file) {
- try {
- await uploadProfilePicture(userId, file)
- setMessage("Profile picture uploaded successfully")
- } catch (error: any) {
- setMessage(`Error uploading profile picture: ${error.message}`)
- }
- } else {
- setMessage("No file selected. Please select a file to upload.")
- }
- }
-
- const handleFileChange = (e: React.ChangeEvent) => {
- if (typeof window !== 'undefined' && window.File) {
- const selectedFile: File | null = e.target.files?.[0] || null;
- setFile(selectedFile as any);
- if (selectedFile) {
- setProfilePicture(URL.createObjectURL(selectedFile));
- }
- }
- }
-
- const handleSubmit = () => {
- form.handleSubmit(onSubmit)()
- }
-
- return (
- <>
-
-
-
- { e.preventDefault(); handleSubmit(); }} className="space-y-6 text-white">
-
- Profile Picture
-
-
-
-
-
-
- {profilePicture ? (
-
- ) : (
-
- {username.substring(0, 2).toUpperCase()}
-
- )}
-
-
-
-
-
-
-
- {message && {message}
}
- Upload
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(dashboard)/settings/server/page.tsx b/apps/web/app/(dashboard)/settings/server/page.tsx
deleted file mode 100644
index 0e4bd326..00000000
--- a/apps/web/app/(dashboard)/settings/server/page.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-"use client"
-
-import { getServerInfo, setServerInfo } from '@music/sdk';
-import { useRouter } from 'next/navigation';
-import React, { useEffect, useState } from 'react';
-
-
-export default function ServerPage() {
-
- const [localAddress, setLocalAddress] = useState('');
- const [serverName, setServerName] = useState('');
- const [loginDisclaimer, setLoginDisclaimer] = useState('');
-
- useEffect(() => {
- async function fetchServerInfo() {
- const serverInfo = await getServerInfo();
- setLocalAddress(serverInfo.local_address);
- setServerName(serverInfo.server_name);
- setLoginDisclaimer(serverInfo.login_disclaimer);
- }
-
- fetchServerInfo();
- }, []);
-
- const { push } = useRouter()
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- await setServerInfo(localAddress, serverName, "1.0.0", "ParsonLabs Music", true, loginDisclaimer);
- localStorage.setItem("server", JSON.stringify({ local_address: localAddress, server_name: serverName, version: "1.0.0", product_name: "ParsonLabs Music", startup_wizard_completed: true, login_disclaimer: loginDisclaimer }))
- };
-
- return (
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/app/(dashboard)/settings/users/page.tsx b/apps/web/app/(dashboard)/settings/users/page.tsx
deleted file mode 100644
index 2ef52e09..00000000
--- a/apps/web/app/(dashboard)/settings/users/page.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-import { Button } from "@music/ui/components/button"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form"
-import { Input } from "@music/ui/components/input"
-import { Checkbox } from "@music/ui/components/checkbox"
-import { register } from "@music/sdk"
-
-const formSchema = z.object({
- username: z.string().min(2, {
- message: "Username must be at least 2 characters.",
- }),
- password: z.string().min(6, {
- message: "Password must be at least 6 characters.",
- }),
- admin: z.boolean().default(false).optional(),
-})
-
-export default function UsersPage() {
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- username: "",
- password: "",
- admin: false,
- },
- })
-
- function onSubmit(values: z.infer) {
- const role = values.admin ? "admin" : "user"
- register({ username: values.username, password: values.password, role })
- }
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(unprotected)/connect/page.tsx b/apps/web/app/(unprotected)/connect/page.tsx
new file mode 100644
index 00000000..ab2afba0
--- /dev/null
+++ b/apps/web/app/(unprotected)/connect/page.tsx
@@ -0,0 +1,19 @@
+import ServerConnectionPanel from "@/features/server/server-connection-panel";
+
+export default function ConnectPage() {
+ return (
+
+
+ Parson servers
+
+
+ Choose a library
+
+
+ Use the library hosted on this device or connect directly to another
+ Parson server.
+
+
+
+ );
+}
diff --git a/apps/web/app/(unprotected)/layout.tsx b/apps/web/app/(unprotected)/layout.tsx
index 9e38680c..aa589987 100644
--- a/apps/web/app/(unprotected)/layout.tsx
+++ b/apps/web/app/(unprotected)/layout.tsx
@@ -1,41 +1,24 @@
-import "@music/ui/globals.css"
-import { Inter as FontSans } from "next/font/google"
+import type { ReactNode } from "react";
+import Image from "next/image";
-import pl from "@/assets/pl-tp.png"
-import { cn } from "@music/ui/lib/utils"
-import { Metadata } from "next"
-import Image from "next/image"
-
-const fontSans = FontSans({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-export const metadata: Metadata = {
- title: "ParsonLabs Music"
-}
-
-type RootLayoutProps = {
- children: React.ReactNode
-}
-
-export default function RootLayout({ children }: RootLayoutProps) {
+export default function UnprotectedLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
return (
-
-
-
-
-
-
- ParsonLabs Music
-
-
- {children}
-
-
- )
+
+ );
}
diff --git a/apps/web/app/(unprotected)/login/layout.tsx b/apps/web/app/(unprotected)/login/layout.tsx
new file mode 100644
index 00000000..dc4e0287
--- /dev/null
+++ b/apps/web/app/(unprotected)/login/layout.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+
+export const metadata: Metadata = {
+ title: "Log in",
+ description: "Log in to Parson.",
+};
+
+export default function LoginLayout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/apps/web/app/(unprotected)/login/page.tsx b/apps/web/app/(unprotected)/login/page.tsx
index eb492426..0e8631fd 100644
--- a/apps/web/app/(unprotected)/login/page.tsx
+++ b/apps/web/app/(unprotected)/login/page.tsx
@@ -1,116 +1,82 @@
-"use client"
+"use client";
-import getBaseURL from "@/lib/Server/getBaseURL";
+import { useState, type FormEvent } from "react";
+import { useRouter } from "next/navigation";
+import { login, refreshMediaToken } from "@parson/music-sdk";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { useSession } from "@/features/account/session-provider";
+import { toast } from "sonner";
+import Link from "next/link";
+import ParsonBrandMark from "@/components/icons/parson-brand-mark";
-import { zodResolver } from '@hookform/resolvers/zod';
-import { Button } from '@music/ui/components/button';
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form";
-import { Input } from '@music/ui/components/input';
-import { setCookie } from 'cookies-next';
-import { useRouter } from 'next/navigation';
-import { useState } from 'react';
-import { SubmitHandler, useForm } from 'react-hook-form';
-import * as z from 'zod';
-
-const schema = z.object({
- username: z.string().min(1, { message: 'Username is required' }),
- password: z.string().min(1, { message: 'Password is required' }),
-});
-
-type FormData = z.infer;
-
-export default function Login() {
+export default function LoginPage() {
const router = useRouter();
- const [errorMessage, setErrorMessage] = useState(null);
- const form = useForm({
- resolver: zodResolver(schema),
- });
-
- const { handleSubmit } = form;
+ const { setSession } = useSession();
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [submitting, setSubmitting] = useState(false);
- const onSubmit: SubmitHandler = async (data) => {
- try {
- const response = await fetch(`${getBaseURL()}/api/auth/login`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(data),
- credentials: 'include'
- });
-
- if (response.ok) {
- router.push("/");
- } else {
- const errorMessage = await response.text();
- setErrorMessage(errorMessage || 'Authentication failed');
- }
- } catch (error) {
- console.error('Login error:', error);
- setErrorMessage('An error occurred during login');
- }
- };
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setSubmitting(true);
+ try {
+ const result = await login({ username, password });
+ if (!result.status) {
+ toast(result.message || "Sign in failed.");
+ return;
+ }
+ if (!result.claims) throw new Error("Sign in response had no session");
+ const media = await refreshMediaToken();
+ if (!media.status || !media.media_token) {
+ throw new Error("Media authorization unavailable");
+ }
+ setSession(result.claims);
+ const next = new URLSearchParams(window.location.search).get("next");
+ router.replace(next === "/setup" ? next : "/");
+ } catch {
+ toast("Could not reach the server.");
+ } finally {
+ setSubmitting(false);
+ }
+ }
return (
-
+
+
+
+ Welcome back
+ setUsername(event.target.value)}
+ />
+ setPassword(event.target.value)}
+ />
+
+ {submitting ? "Signing in…" : "Sign in"}
+
+
+ Connect to another server
+
+
+
);
}
diff --git a/apps/web/app/(unprotected)/page.tsx b/apps/web/app/(unprotected)/page.tsx
deleted file mode 100644
index 2cb42755..00000000
--- a/apps/web/app/(unprotected)/page.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-"use client";
-
-import pl from "@/assets/pl-tp.png";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { zodResolver } from '@hookform/resolvers/zod';
-import { getServerInfo } from "@music/sdk";
-import { ServerInfo } from "@music/sdk/types";
-import { Button } from '@music/ui/components/button';
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form";
-import { Input } from '@music/ui/components/input';
-import { Loader2Icon } from "lucide-react";
-import Image from "next/image";
-import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
-import { SubmitHandler, useForm } from 'react-hook-form';
-import * as z from 'zod';
-
-const schema = z.object({
- serverUrl: z.string().url({ message: 'Invalid URL' }).min(1, { message: 'Server URL is required' }),
-});
-
-type FormData = z.infer;
-
-export default function MainPage() {
- const [loading, setLoading] = useState(true);
- const [showServerURLInput, setShowServerURLInput] = useState(false);
- const { push } = useRouter();
-
- useEffect(() => {
- const checkServerUrl = async () => {
- setLoading(true);
-
- try {
- const storedServer = localStorage.getItem("server");
- const response = await fetch(`${storedServer && JSON.parse(storedServer).local_address || window.location.origin}/api/s/server/info`);
- let serverInfo: ServerInfo = await response.json();
-
- const session = getSession();
- if (serverInfo.product_name && serverInfo.startup_wizard_completed) {
- localStorage.setItem("server", JSON.stringify(serverInfo));
-
- if (session) {
- push("/home");
- } else {
- push("/login");
- }
- } else {
- if (!serverInfo.startup_wizard_completed && session) {
- push("/setup/library");
- } else {
- push("/setup");
- }
- }
- } catch (error) {
- console.error("Error checking server URL:", error);
- push("/setup");
- } finally {
- setLoading(false);
- }
- };
-
- checkServerUrl();
- }, [push]);
-
- const form = useForm({
- resolver: zodResolver(schema),
- });
-
- const { handleSubmit } = form;
-
- const onSubmit: SubmitHandler = async (data) => {
- setLoading(true);
-
- try {
- localStorage.setItem("server", JSON.stringify({ local_address: data.serverUrl }));
- let serverInfo = await getServerInfo();
-
- const session = getSession();
- if (serverInfo.product_name && serverInfo.startup_wizard_completed) {
- localStorage.setItem("server", JSON.stringify(serverInfo));
-
- if (session) {
- push("/home");
- } else {
- push("/login");
- }
- } else {
- if (!serverInfo.startup_wizard_completed && session) {
- push("/setup/library");
- } else {
- push("/setup")
- }
- }
- } catch (error) {
- push("/setup");
- } finally {
- setLoading(false);
- }
- };
-
- if (loading) {
- return (
-
- );
- }
-
- if (showServerURLInput) {
- return (
-
-
-
- Enter Server URL
- (
-
- Server URL
-
-
-
- {form.formState.errors.serverUrl?.message?.toString()}
-
- )}
- />
-
- Submit
-
-
-
-
- );
- }
-
- return null;
-}
\ No newline at end of file
diff --git a/apps/web/app/(unprotected)/setup/account/page.tsx b/apps/web/app/(unprotected)/setup/account/page.tsx
deleted file mode 100644
index d1b56192..00000000
--- a/apps/web/app/(unprotected)/setup/account/page.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { ArrowRight } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { login, register } from "@music/sdk"
-import { Button } from "@music/ui/components/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form"
-import { Input } from "@music/ui/components/input"
-import Link from "next/link"
-import { useRouter } from "next/navigation"
-
-const registerSchema = z.object({
- username: z.string().min(1, { message: "Username must be longer than 1 character" }),
- password: z.string().min(4, { message: "Password must be longer than 4 character" })
-})
-
-export default function Register() {
- const form = useForm>({
- resolver: zodResolver(registerSchema),
- defaultValues: {
- username: "",
- password: ""
- },
- })
-
-const { push } = useRouter()
-
-async function onSubmit(values: z.infer) {
- await register({ username: values.username, password: values.password, role: "admin" })
- await login({ username: values.username, password: values.password, role: "admin" })
- push("/setup/library")
-}
-
- return (
-
-
Account
-
Tell us About Yourself
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/app/(unprotected)/setup/complete/page.tsx b/apps/web/app/(unprotected)/setup/complete/page.tsx
deleted file mode 100644
index 59ce52cf..00000000
--- a/apps/web/app/(unprotected)/setup/complete/page.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client"
-
-import { getServerInfo, setServerInfo } from "@music/sdk";
-import { Button } from "@music/ui/components/button";
-import { useRouter } from "next/navigation";
-
-export default function Setup() {
- const router = useRouter()
-
- async function completeStartupWizard() {
- const serverInfo = await getServerInfo();
- await setServerInfo(
- serverInfo.local_address,
- serverInfo.server_name,
- serverInfo.version,
- serverInfo.version,
- true,
- serverInfo.login_disclaimer
- );
-
- router.push("/home")
- }
-
- return (
-
-
Setup Completed!
-
-
- See your Music
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/app/(unprotected)/setup/layout.tsx b/apps/web/app/(unprotected)/setup/layout.tsx
deleted file mode 100644
index 67cc789d..00000000
--- a/apps/web/app/(unprotected)/setup/layout.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client"
-
-import "@music/ui/globals.css"
-import { Inter as FontSans } from "next/font/google"
-
-import pl from "@/assets/pl-tp.png"
-import { cn } from "@music/ui/lib/utils"
-import Image from "next/image"
-import { getServerInfo } from "@music/sdk"
-import { ServerInfo } from "@music/sdk/types"
-import { useState, useEffect } from "react"
-import { useRouter } from "next/navigation"
-
-
-const fontSans = FontSans({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-type RootLayoutProps = {
- children: React.ReactNode
-}
-
-export default function RootLayout({ children }: RootLayoutProps) {
- const [serverInfo, setServerInfo] = useState(null);
- const router = useRouter()
-
- useEffect(() => {
- const fetchServerInfo = async () => {
- const info = await getServerInfo();
- setServerInfo(info);
- };
-
- fetchServerInfo();
- }, []);
-
- if (!serverInfo || !serverInfo.startup_wizard_completed) {
- return (
-
-
-
-
-
-
- ParsonLabs Music
-
-
- {children}
-
-
- );
- }
-
- return router.push("/home");;
-}
\ No newline at end of file
diff --git a/apps/web/app/(unprotected)/setup/library/page.tsx b/apps/web/app/(unprotected)/setup/library/page.tsx
deleted file mode 100644
index 52e38aaa..00000000
--- a/apps/web/app/(unprotected)/setup/library/page.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";;
-
-import React, { useState, useEffect } from "react";
-import useWebSocket from "react-use-websocket";
-import { ScrollArea, ScrollBar } from "@music/ui/components/scroll-area";
-import FileBrowser from "@/components/FileBrowser/FileBrowser";
-import Link from "next/link";
-
-export default function SetupLibrary() {
- const [messageHistory, setMessageHistory] = useState[]>([]);
-
- const baseUrl = getBaseURL()?.replace(/^https?:\/\//, '');
- const socketUrl = `ws://${baseUrl ?? window.location.hostname}/ws`;
- const { lastMessage } = useWebSocket(socketUrl);
-
- useEffect(() => {
- if (lastMessage !== null && typeof lastMessage.data === 'string') {
- const newMessage = new MessageEvent("message", { data: lastMessage.data });
- setMessageHistory((prev) => prev.concat(newMessage));
- } else {
- console.error("Unsupported message data type:", typeof lastMessage?.data);
- }
- }, [lastMessage]);
-
- return (
- <>
-
-
-
-
-
-
-
Logs
-
-
-
- {messageHistory.map((message) => (
- {message.data}
- ))}
-
-
-
-
-
-
-
- Continue
-
-
- >
- );
-}
diff --git a/apps/web/app/(unprotected)/setup/page.tsx b/apps/web/app/(unprotected)/setup/page.tsx
index db1a7f86..83e39f9f 100644
--- a/apps/web/app/(unprotected)/setup/page.tsx
+++ b/apps/web/app/(unprotected)/setup/page.tsx
@@ -1,17 +1,22 @@
-import { Button } from "@music/ui/components/button";
-import Link from "next/link";
+import SetupFlow from "@/features/setup/setup-flow";
+import type { Metadata } from "next";
+import { Suspense } from "react";
-export default function Setup() {
+export const metadata: Metadata = {
+ title: "Set up Parson",
+ description: "Create your account and add your music.",
+};
+
+export default function SetupPage() {
return (
-
-
Setup ParsonLabs Music
-
-
-
- Get Started
-
-
-
-
+
+ Getting things ready
+
+ }
+ >
+
+
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/app/(unprotected)/setup/server/page.tsx b/apps/web/app/(unprotected)/setup/server/page.tsx
deleted file mode 100644
index 4b599d33..00000000
--- a/apps/web/app/(unprotected)/setup/server/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-"use client"
-
-import { setServerInfo } from '@music/sdk';
-import { useRouter } from 'next/navigation';
-import React, { useState } from 'react';
-
-export default function ServerPage() {
- const [localAddress, setLocalAddress] = useState('');
- const [serverName, setServerName] = useState('');
- const [loginDisclaimer, setLoginDisclaimer] = useState('');
-
- const { push } = useRouter()
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- localStorage.setItem("server", JSON.stringify({ local_address: localAddress }))
- await setServerInfo(localAddress, serverName, "1.0.0", "ParsonLabs Music", false, loginDisclaimer);
- localStorage.setItem("server", JSON.stringify({ local_address: localAddress, server_name: serverName, version: "1.0.0", product_name: "ParsonLabs Music", startup_wizard_completed: true, login_disclaimer: loginDisclaimer }))
- push("/setup/account")
- };
-
- return (
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx
new file mode 100644
index 00000000..0666570b
--- /dev/null
+++ b/apps/web/app/error.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect } from "react";
+
+export default function ErrorPage({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error("Route rendering failed", error);
+ }, [error]);
+
+ return (
+
+
+
+ This view could not be loaded
+
+
+ Try again
+
+
+
+ );
+}
diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico
index 967f0d76..a6efb21e 100644
Binary files a/apps/web/app/favicon.ico and b/apps/web/app/favicon.ico differ
diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx
new file mode 100644
index 00000000..d6f05412
--- /dev/null
+++ b/apps/web/app/global-error.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+export default function GlobalError({ reset }: { reset: () => void }) {
+ return (
+
+
+
+
+
Something went wrong
+
+ Recover
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
new file mode 100644
index 00000000..9773d733
--- /dev/null
+++ b/apps/web/app/globals.css
@@ -0,0 +1,143 @@
+@import "tailwindcss";
+
+@theme inline {
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+ --color-card: hsl(var(--card));
+ --color-card-foreground: hsl(var(--card-foreground));
+ --color-popover: hsl(var(--popover));
+ --color-popover-foreground: hsl(var(--popover-foreground));
+ --color-primary: hsl(var(--primary));
+ --color-primary-foreground: hsl(var(--primary-foreground));
+ --color-secondary: hsl(var(--secondary));
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+ --color-accent: hsl(var(--accent));
+ --color-accent-foreground: hsl(var(--accent-foreground));
+ --color-destructive: hsl(var(--destructive));
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
+ --color-border: hsl(var(--border));
+ --color-input: hsl(var(--input));
+ --color-ring: hsl(var(--ring));
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+}
+
+:root {
+ --background: 0 0% 3%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 7%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 6%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 96%;
+ --primary-foreground: 0 0% 3%;
+ --secondary: 0 0% 12%;
+ --secondary-foreground: 0 0% 96%;
+ --muted: 0 0% 12%;
+ --muted-foreground: 0 0% 63%;
+ --accent: 0 0% 92%;
+ --accent-foreground: 0 0% 3%;
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 15%;
+ --input: 0 0% 15%;
+ --ring: 0 0% 74%;
+}
+
+* {
+ border-color: hsl(var(--border));
+ scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
+}
+
+html,
+body {
+ min-height: 100%;
+ background: #000;
+ color: hsl(var(--foreground));
+}
+
+html {
+ scrollbar-gutter: stable;
+}
+
+body {
+ letter-spacing: 0;
+}
+
+.electron-titlebar-drag {
+ -webkit-app-region: drag;
+}
+
+.electron-titlebar-no-drag {
+ -webkit-app-region: no-drag;
+}
+
+@keyframes player-title-marquee {
+ 0% {
+ transform: translateX(0);
+ }
+ 100% {
+ transform: translateX(calc(-50% - 1rem));
+ }
+}
+
+@keyframes control-tooltip {
+ 0%,
+ 72% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+.player-title-marquee {
+ animation: player-title-marquee 12s linear infinite alternate;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .player-title-marquee {
+ animation: none;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .player-title-marquee [aria-hidden="true"] {
+ display: none;
+ }
+}
+
+::selection {
+ background: rgba(255, 255, 255, 0.22);
+ color: #fff;
+}
+
+a:focus-visible,
+button:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.3);
+ outline-offset: 3px;
+}
+
+.tidal-route {
+ margin-inline: auto;
+ min-height: 100%;
+ width: 100%;
+ max-width: 920px;
+ padding: 1.5rem 1rem 3rem;
+}
+
+@media (min-width: 640px) {
+ .tidal-route {
+ padding-inline: 1.5rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .tidal-route {
+ padding-inline: 2rem;
+ }
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
new file mode 100644
index 00000000..70be536d
--- /dev/null
+++ b/apps/web/app/layout.tsx
@@ -0,0 +1,69 @@
+import "./globals.css";
+
+import type { Metadata, Viewport } from "next";
+import { Inter } from "next/font/google";
+import type { ReactNode } from "react";
+import SessionProvider from "@/features/account/session-provider";
+import AppBootstrap from "@/components/app/splash-screen";
+
+import { Toaster } from "sonner";
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ metadataBase: new URL(
+ process.env.NEXT_PUBLIC_SITE_URL ?? "https://parson.dev",
+ ),
+ applicationName: "Parson",
+ title: "Parson",
+ description: "Own your music.",
+ manifest: "/site.webmanifest",
+ icons: {
+ icon: [
+ { url: "/favicon.ico", sizes: "any" },
+ { url: "/icons/icon.svg", type: "image/svg+xml" },
+ { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ ],
+ apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }],
+ },
+ openGraph: {
+ title: "Parson",
+ description: "Own your music.",
+ images: [{ url: "/images/og/parson.jpg", width: 1200, height: 630 }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "Parson",
+ description: "Own your music.",
+ images: ["/images/og/parson.jpg"],
+ },
+};
+
+export const viewport: Viewport = { themeColor: "#000000" };
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx
new file mode 100644
index 00000000..ec663342
--- /dev/null
+++ b/apps/web/app/not-found.tsx
@@ -0,0 +1,33 @@
+import Link from "next/link";
+import { Library, Home } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Page not found",
+ description: "The requested music page could not be found.",
+};
+
+export default function NotFoundPage() {
+ return (
+
+
+ Page not found
+
+
+
+
+ Home
+
+
+
+
+
+ Library
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/AI/AIOverlayContext.tsx b/apps/web/components/AI/AIOverlayContext.tsx
deleted file mode 100644
index 3aa2a6b4..00000000
--- a/apps/web/components/AI/AIOverlayContext.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client"
-
-import { createContext, useState } from 'react';
-
-export const AIContext = createContext({
- isAIVisible: false,
- toggleAI: () => {},
-});
-
-type AIPanelProviderProps = {
- children: React.ReactNode
-}
-
-export default function AIProvider({ children }: AIPanelProviderProps) {
- const [isAIVisible, setAIVisible] = useState(false);
-
- const toggleAI = () => {
- setAIVisible(prev => !prev);
- };
-
- return (
-
- {children}
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/components/AI/AIPanel.tsx b/apps/web/components/AI/AIPanel.tsx
deleted file mode 100644
index 938dfedb..00000000
--- a/apps/web/components/AI/AIPanel.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-"use client";
-
-import { useContext, useState, useEffect } from "react";
-import { AIContext } from "./AIOverlayContext";
-import { usePlayer } from "../Music/Player/usePlayer";
-import {
- ResizableHandle,
- ResizablePanel,
- ResizablePanelGroup,
-} from "@music/ui/components/resizable";
-import { Ollama } from "ollama/browser";
-import { LyricsContext } from "../Lyrics/LyricsOverlayContext";
-import { ScrollArea } from "@music/ui/components/scroll-area";
-
-type QueuePanelProps = {
- children: React.ReactNode;
-};
-
-function ChildrenComponent({ children }: QueuePanelProps) {
- return (
-
- {children}
-
- );
-}
-
-export default function AIPanel({ children }: QueuePanelProps) {
- const { isAIVisible } = useContext(AIContext);
- const { currentLyrics } = useContext(LyricsContext);
- const { song, artist, album } = usePlayer();
- const [responseContent, setResponseContent] = useState("");
-
- useEffect(() => {
- const ollama = new Ollama({ host: process.env.NEXT_PUBLIC_AI_URL });
- setResponseContent("");
- ollama.abort();
- const fetchOllamaResponse = async () => {
- let releaseDate = new Date(album.first_release_date).toLocaleString(
- "default",
- { month: "long", year: "numeric" }
- );
- const responseStream = await ollama.chat({
- model: "llama3:8b-instruct-q8_0",
- messages: [
- {
- role: "user",
- content: `What is the song "${song.name}" by "${artist.name}" from the album: "${album.name}" released in ${releaseDate} about?\nHere are the lyrics: ${currentLyrics} `,
- },
- ],
- stream: true,
- });
-
- for await (const part of responseStream) {
- setResponseContent((prevContent) => prevContent + part.message.content);
- }
- };
-
- if (isAIVisible) {
- fetchOllamaResponse();
- }
- }, [
- isAIVisible,
- song,
- artist,
- currentLyrics,
- album.first_release_date,
- album.name,
- ]);
-
- return (
-
- {children}
- {isAIVisible && (
- <>
-
-
-
-
- {responseContent}
-
-
-
- >
- )}
-
- );
-}
diff --git a/apps/web/components/Artist/SongsInLibrary.tsx b/apps/web/components/Artist/SongsInLibrary.tsx
deleted file mode 100644
index 804cd15c..00000000
--- a/apps/web/components/Artist/SongsInLibrary.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import getSession from "@/lib/Authentication/JWT/getSession";
-import getBaseURL from "@/lib/Server/getBaseURL";
-import { getPlaylist, getPlaylists, getSongInfo } from "@music/sdk";
-import { LibrarySong } from "@music/sdk/types";
-import { useSearchParams } from "next/navigation";
-import { useEffect, useState } from "react";
-import ScrollButtons from "../Home/ScrollButtons";
-import BigCard from "../Music/Card/BigCard";
-
-async function getSongsFromYourLibrary(user_id: number, artist_id: string) {
- const playlists = await getPlaylists(user_id);
-
- const playlistSongIDsPromises = playlists.map(async (playlist) => {
- const individualPlaylist = await getPlaylist(playlist.id);
- return individualPlaylist.song_infos.map((songInfo) => songInfo.song_id);
- });
-
- const playlistSongIDsArrays = await Promise.all(playlistSongIDsPromises);
- const playlistSongIDs = playlistSongIDsArrays.flat();
-
- const songsDetailsPromises = playlistSongIDs.map((songID) => getSongInfo(String(songID)));
-
- const songsDetails = await Promise.all(songsDetailsPromises);
-
- const filteredSongsDetails = songsDetails.filter(song => String(song.artist_object.id) === artist_id);
-
- return filteredSongsDetails;
-}
-
-export default function FromYourLibrary() {
- const [librarySongs, setLibrarySongs] = useState([]);
- const [loading, setLoading] = useState(true);
-
- const searchParams = useSearchParams();
- const id = searchParams?.get("id");
-
- useEffect(() => {
- const session = getSession();
-
- async function fetchSongs() {
- if (session && id) {
- const songs = await getSongsFromYourLibrary(Number(session.sub), id);
- setLibrarySongs(songs);
- setLoading(false);
- }
- }
-
- fetchSongs();
- }, [id]);
-
- return librarySongs.length > 0 && (
-
-
- {librarySongs.map((song, index) => (
-
-
-
- ))}
-
-
- );
-}
diff --git a/apps/web/components/Authentication/Register.tsx b/apps/web/components/Authentication/Register.tsx
deleted file mode 100644
index c83bbeba..00000000
--- a/apps/web/components/Authentication/Register.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { Button } from "@music/ui/components/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form"
-import { Input } from "@music/ui/components/input"
-
-const registerSchema = z.object({
- username: z.string().min(1, { message: "Username must be longer than 1 character" }),
- password: z.string().min(4, { message: "Password must be longer than 4 character" })
-})
-
-export default function Register() {
- const form = useForm>({
- resolver: zodResolver(registerSchema),
- defaultValues: {
- username: "",
- password: ""
- },
- })
-
-async function onSubmit(values: z.infer) {
- try {
- const response = await fetch('/register', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- username: values.username,
- password: values.password,
- }),
- });
-
- if (response.ok) {
- } else {
- throw new Error('Response was not ok');
- }
- } catch (error) {
- console.error(error);
- }
-}
-
- return (
-
-
-
- (
-
- Username
-
-
-
-
-
- )}
- />
-
- (
-
- Password
-
-
-
-
-
- )}
- />
-
- Submit
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Description/Description.tsx b/apps/web/components/Description/Description.tsx
deleted file mode 100644
index ee0f4dd4..00000000
--- a/apps/web/components/Description/Description.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client"
-
-import { useState } from "react"
-
-type DescriptionProps = { description: string }
-
-export default function Description({ description }: DescriptionProps) {
- const [showFullDescription, setShowFullDescription] = useState(false)
-
- return description &&
- <>
-
- {description}
-
-
- setShowFullDescription(!showFullDescription)} className="py-2">
- {showFullDescription ? 'LESS' : 'MORE'}
-
- >
-}
\ No newline at end of file
diff --git a/apps/web/components/FileBrowser/FileBrowser.tsx b/apps/web/components/FileBrowser/FileBrowser.tsx
deleted file mode 100644
index f033f05b..00000000
--- a/apps/web/components/FileBrowser/FileBrowser.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client"
-
-import { indexLibrary, listDirectory } from "@music/sdk";
-import { Button } from "@music/ui/components/button";
-import { ScrollArea } from "@music/ui/components/scroll-area";
-import { ArrowRight } from "lucide-react";
-import { useEffect, useState } from "react";
-
-interface Directory {
- name: string;
- path: string;
-}
-
-export default function FileBrowser() {
-
- const [currentDirectory, setCurrentDirectory] = useState("/")
- const [currentDirectoryList, setCurrentDirectoryList] = useState([])
-
- async function updateList(directoryPath: string) {
- const directoryList = await listDirectory(directoryPath)
- setCurrentDirectory(directoryPath)
- setCurrentDirectoryList(directoryList)
- }
-
- function getParentDirectory(currentDirectory: string): string {
- const separator = currentDirectory.includes("\\") ? "\\" : "/";
- const parentDirectory = currentDirectory.split(separator).slice(0, -1).join(separator) || separator;
- return parentDirectory
- }
-
- function goBack() {
- const parentDirectory = getParentDirectory(currentDirectory)
- updateList(parentDirectory);
- }
-
- useEffect(() => {
- updateList("/")
- }, [])
-
- return (
-
-
-
Current Directory: {currentDirectory}
-
-
-
-
- {currentDirectoryList.map(directory => (
- updateList(directory.path)}>
-
{directory.name}
-
-
- ))}
-
-
-
-
indexLibrary(currentDirectory)}>Index Library
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Friends/FollowButton.tsx b/apps/web/components/Friends/FollowButton.tsx
deleted file mode 100644
index afcbec6f..00000000
--- a/apps/web/components/Friends/FollowButton.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession"
-import { follow } from "@music/sdk"
-import { Button } from "@music/ui/components/button"
-
-type FollowButtonProps = {
- userIDToFollow: number
-}
-
-export default function FollowButton({ userIDToFollow }: FollowButtonProps) {
- const session = getSession()
-
- return session && follow(Number(session.sub), userIDToFollow)}>Follow
-}
\ No newline at end of file
diff --git a/apps/web/components/Friends/FriendActivity.tsx b/apps/web/components/Friends/FriendActivity.tsx
deleted file mode 100644
index c1c2129d..00000000
--- a/apps/web/components/Friends/FriendActivity.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { getFollowing, getNowPlaying, getSongInfo, getUserInfoById } from "@music/sdk";
-import { Album, Artist, LibrarySong as Song, User } from "@music/sdk/types";
-import { Avatar, AvatarFallback, AvatarImage } from "@music/ui/components/avatar";
-import { Disc3Icon } from 'lucide-react';
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import { getProfilePicture } from "@music/sdk";
-
-type Friend = User & {
- nowPlaying: {
- artist: Artist,
- album: Album,
- song: Song,
- } | null,
- profilePicture: string | null
-}
-
-export default function FriendActivity() {
- const [friends, setFriends] = useState([])
-
- useEffect(() => {
- const session = getSession();
- async function getActivity() {
- if (session) {
- const followingIDs = await getFollowing(Number(session?.sub) ?? "");
- const friends = await Promise.all(followingIDs.map(async (id: number) => {
- const friend = await getUserInfoById(id);
- const nowPlayingSongID = await getNowPlaying(id);
- const profilePicBlob = await getProfilePicture(id);
- const profilePicture = profilePicBlob ? URL.createObjectURL(profilePicBlob) : null;
-
- if (nowPlayingSongID) {
- const songResponse = await getSongInfo(String(nowPlayingSongID.now_playing) ?? 0);
- return {
- ...friend,
- nowPlaying: {
- artist: songResponse.artist_object,
- album: songResponse.album_object,
- song: songResponse,
- },
- profilePicture
- };
- } else {
- return {
- ...friend,
- nowPlaying: null,
- profilePicture
- };
- }
- }));
-
- setFriends(friends as any as Friend[]);
- }
- }
-
- getActivity();
-
- const intervalId = setInterval(getActivity, 5000);
-
- return () => {
- clearInterval(intervalId);
- };
- }, []);
-
- return friends && (
- <>
- {friends.length !== 0 && Friend Activity }
- {friends.map((friend) => (
-
-
- {friend.profilePicture ? (
-
- ) : (
- {friend.username?.substring(0, 2).toUpperCase()}
- )}
-
-
-
{friend.username}
- {friend.nowPlaying && friend.nowPlaying.album &&
- <>
-
{friend.nowPlaying.song.name}
- {friend.nowPlaying.artist &&
{friend.nowPlaying.artist.name}
}
-
-
-
-
20 ? friend.nowPlaying.album.name : ''}
- >
- {friend.nowPlaying.album.name.length > 20
- ? `${friend.nowPlaying.album.name.substring(0, 20)}...`
- : friend.nowPlaying.album.name}
-
-
-
- >
- }
-
-
- ))}
- >
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Home/FromYourLibrary.tsx b/apps/web/components/Home/FromYourLibrary.tsx
deleted file mode 100644
index 3bdac518..00000000
--- a/apps/web/components/Home/FromYourLibrary.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import setCache, { getCache } from "@/lib/Caching/cache";
-import { getPlaylist, getPlaylists, getSongInfo } from "@music/sdk";
-import { LibrarySong } from "@music/sdk/types";
-import { useEffect, useState } from "react";
-import BigCard from "../Music/Card/BigCard";
-import ScrollButtons from "./ScrollButtons";
-
-async function getSongsFromYourLibrary(user_id: number, genre?: string) {
- const playlists = await getPlaylists(user_id);
-
- const playlistSongIDsPromises = playlists.map(async (playlist) => {
- const individualPlaylist = await getPlaylist(playlist.id);
- return individualPlaylist.song_infos.map((songInfo) => songInfo.song_id);
- });
-
- const playlistSongIDsArrays = await Promise.all(playlistSongIDsPromises);
- const playlistSongIDs = playlistSongIDsArrays.flat();
-
- const songsDetailsPromises = playlistSongIDs.map((songID) => getSongInfo(String(songID)));
-
- const songsDetails = await Promise.all(songsDetailsPromises);
-
- if (genre) {
- return songsDetails.filter(song => {
- const releaseAlbumGenres = song.album_object.release_album?.genres?.some(g => g.name === genre);
- const releaseGroupAlbumGenres = song.album_object.release_group_album?.genres?.some(g => g.name === genre);
- return releaseAlbumGenres || releaseGroupAlbumGenres;
- });
- }
-
- return songsDetails;
-}
-
-interface FromYourLibraryProps {
- genre?: string;
-}
-
-export default function FromYourLibrary({ genre }: FromYourLibraryProps) {
- const [librarySongs, setLibrarySongs] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- const session = getSession();
-
- async function fetchSongs() {
- const cacheKey = "fromYourLibrary";
- const cachedData = genre ? null : getCache(cacheKey);
-
- if (cachedData) {
- setLibrarySongs(cachedData);
- setLoading(false);
- } else {
- if (session) {
- const songs = await getSongsFromYourLibrary(Number(session.sub), genre);
- setLibrarySongs(songs);
- setLoading(false);
- if (!genre) {
- setCache(cacheKey, songs, 3600000);
- }
- }
- }
- }
-
- fetchSongs();
- }, [genre]);
-
- if (loading) return null;
- if (!librarySongs || librarySongs.length === 0) return null;
-
- return (
-
-
- {librarySongs.map((song, index) => (
-
-
-
- ))}
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Home/LandingCarousel.tsx b/apps/web/components/Home/LandingCarousel.tsx
deleted file mode 100644
index 30178fd9..00000000
--- a/apps/web/components/Home/LandingCarousel.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-
-import setCache, { getCache } from "@/lib/Caching/cache";
-import { getRandomAlbum } from "@music/sdk";
-import { Album, Artist, LibrarySong } from "@music/sdk/types";
-import { Button } from "@music/ui/components/button";
-import { Skeleton } from "@music/ui/components/skeleton";
-import { Play } from "lucide-react";
-import Image from "next/image";
-import Link from "next/link";
-import { useEffect, useState } from "react";
-
-async function getRandomAlbumAndSongs(): Promise<{ album: Album & { artist_object: Artist }, songs: LibrarySong[] }> {
- let album: Album & { artist_object: Artist } | undefined;
-
- while (!album || !album.cover_url) {
- const randomAlbumRequest = await getRandomAlbum(1);
- album = randomAlbumRequest[0];
- }
-
- const songs = shuffleSongs(album.songs).slice(0, 3);
- return { album, songs };
-}
-
-function shuffleSongs(array: any[]) {
- for (let i = array.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [array[i], array[j]] = [array[j], array[i]];
- }
-
- for (let i = 2; i < array.length; i++) {
- if (array[i].name === array[i - 1].name && array[i].name === array[i - 2].name) {
- for (let j = i + 1; j < array.length; j++) {
- if (array[j].name !== array[i].name) {
- [array[i], array[j]] = [array[j], array[i]];
- break;
- }
- }
- }
- }
-
- return array;
-}
-
-function sanitizeSongName(songName: string) {
- return songName.replace(/\s+/g, '_').replace(/[^\w-]+/g, '');
-}
-
-export function LandingCarouselSkeleton() {
- return (
-
- )
-}
-
-export default function LandingCarousel() {
- const [album, setAlbum] = useState(null);
- const [songs, setSongs] = useState([]);
-
- useEffect(() => {
- async function fetchAlbumAndSongs() {
- const cachedData = getCache("landingCarousel");
-
- if (cachedData) {
- setAlbum(cachedData.album);
- setSongs(cachedData.songs);
- } else {
- const { album, songs } = await getRandomAlbumAndSongs();
- setAlbum(album);
- setSongs(songs);
- setCache("landingCarousel", { album, songs }, 86400000);
- }
- }
-
- fetchAlbumAndSongs();
- }, []);
-
- if (!album) return null;
-
- const albumCoverURL = `${getBaseURL()}/image/${encodeURIComponent(album.cover_url)}?raw=true`;
-
- return (
-
-
-
-
-
-
-
-
{album.name}
-
-
-
{album.artist_object.name}
-
-
-
- Play
-
-
-
-
-
-
Featuring songs like:
-
- {songs.map((song, index) => (
-
-
- {song.name}
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/components/Home/ListenAgain.tsx b/apps/web/components/Home/ListenAgain.tsx
deleted file mode 100644
index 6e7a5d0b..00000000
--- a/apps/web/components/Home/ListenAgain.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import getBaseURL from "@/lib/Server/getBaseURL";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import setCache, { getCache } from "@/lib/Caching/cache";
-import { getListenHistory, getSongInfo } from "@music/sdk";
-import { LibrarySong } from "@music/sdk/types";
-import { useEffect, useState } from "react";
-import PageGradient from "../Layout/PageGradient";
-import BigCard from "../Music/Card/BigCard";
-import ScrollButtons from "./ScrollButtons";
-
-interface ListenAgainProps {
- genre?: string;
-}
-
-export default function ListenAgain({ genre }: ListenAgainProps) {
- const [listenHistorySongs, setListenHistorySongs] = useState([]);
-
- useEffect(() => {
- const fetchListenHistory = async () => {
- const cacheKey = genre ? `listenAgain_${genre}` : "listenAgain";
- const cachedData = genre ? null : getCache(cacheKey);
-
- if (cachedData) {
- setListenHistorySongs(cachedData);
- } else {
- const session = getSession();
- if (session && session.sub) {
- const userId = Number(session.sub);
- if (!isNaN(userId) && userId > 0) {
- const listenHistoryItems = await getListenHistory(userId);
- const uniqueListenHistoryItems = Array.from(new Set(listenHistoryItems.map(item => item.song_id)));
- const songDetailsPromises = uniqueListenHistoryItems.reverse().slice(0, 30).map(song_id => getSongInfo(song_id));
- const songDetails = await Promise.all(songDetailsPromises);
-
- const filteredSongs = genre
- ? songDetails.filter(song => {
- const releaseAlbumGenres = song.album_object.release_album?.genres?.some(g => g.name === genre);
- const releaseGroupAlbumGenres = song.album_object.release_group_album?.genres?.some(g => g.name === genre);
- return releaseAlbumGenres || releaseGroupAlbumGenres;
- })
- : songDetails;
-
- setListenHistorySongs(filteredSongs);
- if (!genre) {
- setCache(cacheKey, filteredSongs, 3600000);
- }
- } else {
- console.error("Invalid user ID:", userId);
- }
- } else {
- console.error("Invalid session or session.sub:", session);
- }
- }
- };
-
- fetchListenHistory();
- }, [genre]);
-
- if (!(listenHistorySongs[0]) || listenHistorySongs.length === 0) return null;
-
- const albumCoverSrc = listenHistorySongs[0].album_object.cover_url.length === 0
- ? "/snf.png"
- : `${getBaseURL()}/image/${encodeURIComponent(listenHistorySongs[0].album_object.cover_url)}`;
-
- return listenHistorySongs && (
- <>
-
-
-
- {listenHistorySongs.map((song, index) => (
-
-
-
- ))}
-
-
- >
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Home/MusicVideos.tsx b/apps/web/components/Home/MusicVideos.tsx
deleted file mode 100644
index 72e9d411..00000000
--- a/apps/web/components/Home/MusicVideos.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { getSongsWithMusicVideos } from "@music/sdk";
-import { useEffect, useState } from "react";
-import MusicVideoCard from "../Music/Card/MusicVideoCard";
-import ScrollButtons from "./ScrollButtons";
-import { MusicVideoSong } from "@music/sdk/types";
-import setCache, { getCache } from "@/lib/Caching/cache";
-
-export default function MusicVideos() {
- const [songs, setSongs] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- async function fetchAllMusicVideos() {
- const cacheKey = "musicVideos";
- const cachedData = getCache(cacheKey);
-
- if (cachedData) {
- setSongs(cachedData);
- setLoading(false);
- } else {
- const allMusicVideos = await getSongsWithMusicVideos();
-
- const uniqueNames = new Set();
- const uniqueMusicVideos = [];
-
- for (const song of allMusicVideos) {
- if (!uniqueNames.has(song.name)) {
- uniqueNames.add(song.name);
- uniqueMusicVideos.push(song);
- }
- }
-
- const randomizedMusicVideos = uniqueMusicVideos.sort(() => 0.5 - Math.random());
-
- const musicVideos = randomizedMusicVideos.slice(0, 30);
-
- setSongs(musicVideos);
- setLoading(false);
- setCache(cacheKey, musicVideos, 3600000);
- }
- }
-
- fetchAllMusicVideos();
- }, []);
-
- if (loading) return null;
-
- return songs && (
-
-
- {songs.map((song) => (
-
- ))}
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Home/RandomSongs.tsx b/apps/web/components/Home/RandomSongs.tsx
deleted file mode 100644
index 35a7e340..00000000
--- a/apps/web/components/Home/RandomSongs.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import setCache, { getCache } from "@/lib/Caching/cache";
-import getBaseURL from "@/lib/Server/getBaseURL";
-import { getRandomSong } from "@music/sdk";
-import { LibrarySong } from "@music/sdk/types";
-import { useEffect, useState } from "react";
-import BigCard from "../Music/Card/BigCard";
-import ScrollButtons from "./ScrollButtons";
-
-export const revalidate = 3600;
-
-async function getRandomSongs(genre?: string) {
- const randomSongs = await getRandomSong(10);
- if (genre) {
- return randomSongs.filter(song => {
- const releaseAlbumGenres = song.album_object.release_album?.genres?.some(g => g.name === genre);
- const releaseGroupAlbumGenres = song.album_object.release_group_album?.genres?.some(g => g.name === genre);
- return releaseAlbumGenres || releaseGroupAlbumGenres;
- });
- }
- return randomSongs;
-}
-
-interface RandomSongsProps {
- genre?: string;
-}
-
-export default function RandomSongs({ genre }: RandomSongsProps) {
- const [randomSongs, setRandomSongs] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- async function fetchRandomSongs() {
- const cacheKey = "randomSongs";
- const cachedData = genre ? null : getCache(cacheKey);
-
- if (cachedData) {
- setRandomSongs(cachedData);
- setLoading(false);
- } else {
- const songs = await getRandomSongs(genre);
- setRandomSongs(songs);
- setLoading(false);
- if (!genre) {
- setCache(cacheKey, songs, 3600000);
- }
- }
- }
-
- fetchRandomSongs();
- }, [genre]);
-
- if (loading) return null;
- if (!randomSongs || randomSongs.length === 0) return null;
-
- const session = getSession();
-
- return (
-
-
- {randomSongs.map((song, index) => (
-
-
-
- ))}
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Home/RecommendedAlbums.tsx b/apps/web/components/Home/RecommendedAlbums.tsx
deleted file mode 100644
index 607d7071..00000000
--- a/apps/web/components/Home/RecommendedAlbums.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import setCache, { getCache } from "@/lib/Caching/cache";
-import { getPlaylist, getPlaylists, getSongInfo } from "@music/sdk";
-import { useEffect, useState } from "react";
-import AlbumCard from "../Music/Card/Album/AlbumCard";
-import ScrollButtons from "./ScrollButtons";
-
-async function getSongsFromYourLibrary(user_id: number, genre?: string) {
- const playlists = await getPlaylists(user_id);
-
- const playlistSongIDsPromises = playlists.map(async (playlist) => {
- const individualPlaylist = await getPlaylist(playlist.id);
- return individualPlaylist.song_infos.map((songInfo) => songInfo.song_id);
- });
-
- const playlistSongIDsArrays = await Promise.all(playlistSongIDsPromises);
- const playlistSongIDs = playlistSongIDsArrays.flat();
-
- const songsDetailsPromises = playlistSongIDs.map((songID) => getSongInfo(String(songID)));
-
- const songsDetails = await Promise.all(songsDetailsPromises);
-
- if (genre) {
- return songsDetails.filter(song => {
- const releaseAlbumGenres = song.album_object.release_album?.genres?.some(g => g.name === genre);
- const releaseGroupAlbumGenres = song.album_object.release_group_album?.genres?.some(g => g.name === genre);
- return releaseAlbumGenres || releaseGroupAlbumGenres;
- });
- }
-
- return songsDetails;
-}
-
-interface RecommendedAlbumsProps {
- genre?: string;
-}
-
-export default function RecommendedAlbums({ genre }: RecommendedAlbumsProps) {
- const [librarySongs, setLibrarySongs] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- const session = getSession();
-
- async function fetchSongs() {
- const cacheKey = genre ? `recommendedAlbums_${genre}` : "recommendedAlbums";
- const cachedData = genre ? null : getCache(cacheKey);
-
- if (cachedData) {
- setLibrarySongs(cachedData);
- setLoading(false);
- } else {
- if (session) {
- const songs = await getSongsFromYourLibrary(Number(session.sub), genre);
- setLibrarySongs(songs);
- setLoading(false);
- if (!genre) {
- setCache(cacheKey, songs, 3600000);
- }
- }
- }
- }
-
- fetchSongs();
- }, [genre]);
-
- if (loading) return null;
- if (!librarySongs || librarySongs.length === 0) return null;
-
- return (
-
-
- {librarySongs.map((song, index) => (
-
- ))}
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Home/ScrollButtons.tsx b/apps/web/components/Home/ScrollButtons.tsx
deleted file mode 100644
index 503180a3..00000000
--- a/apps/web/components/Home/ScrollButtons.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-"use client"
-
-import { ScrollArea, ScrollBar } from '@music/ui/components/scroll-area';
-import { ChevronLeft, ChevronRight } from 'lucide-react';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-
-type ScrollButtonsProps = {
- children: React.ReactNode;
- heading: string;
-};
-
-export default function ScrollButtons({ children, heading }: ScrollButtonsProps) {
- const scrollRef = useRef(null);
- const [isAtStart, setIsAtStart] = useState(true);
- const [isAtEnd, setIsAtEnd] = useState(false);
- const [canScroll, setCanScroll] = useState(false);
-
- const checkScrollPosition = useCallback(() => {
- if (scrollRef.current) {
- const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
- setIsAtStart(scrollLeft === 0);
- setIsAtEnd(scrollLeft + clientWidth >= scrollWidth);
- setCanScroll(scrollWidth > clientWidth);
- }
- }, []);
-
- useEffect(() => {
- const currentScrollRef = scrollRef.current;
- checkScrollPosition();
- if (currentScrollRef) {
- currentScrollRef.addEventListener('scroll', checkScrollPosition);
- }
- return () => {
- if (currentScrollRef) {
- currentScrollRef.removeEventListener('scroll', checkScrollPosition);
- }
- };
- }, [checkScrollPosition]);
-
- const scrollLeft = useCallback(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollBy({ left: -750, behavior: 'smooth' });
- }
- }, []);
-
- const scrollRight = useCallback(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollBy({ left: 750, behavior: 'smooth' });
- }
- }, []);
-
- const showAll = useCallback(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollTo({ left: scrollRef.current.scrollWidth, behavior: 'smooth' });
- }
- }, []);
-
- return (
- <>
-
-
{heading}
-
- {false ? (
-
- More
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- {children}
-
-
- >
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/ArrowPath.tsx b/apps/web/components/Icons/ArrowPath.tsx
deleted file mode 100644
index 44073dea..00000000
--- a/apps/web/components/Icons/ArrowPath.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function ArrowPath(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/ArrowUpCircle.tsx b/apps/web/components/Icons/ArrowUpCircle.tsx
deleted file mode 100644
index bf0f6cdd..00000000
--- a/apps/web/components/Icons/ArrowUpCircle.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function ArrowUpCircle(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/Bars3Left.tsx b/apps/web/components/Icons/Bars3Left.tsx
deleted file mode 100644
index 21bda0eb..00000000
--- a/apps/web/components/Icons/Bars3Left.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function Bars3Left(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/CheckCircle.tsx b/apps/web/components/Icons/CheckCircle.tsx
deleted file mode 100644
index 46742fd7..00000000
--- a/apps/web/components/Icons/CheckCircle.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function CheckCircle(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/IconGoToArtist.tsx b/apps/web/components/Icons/IconGoToArtist.tsx
deleted file mode 100644
index cd1cc9c7..00000000
--- a/apps/web/components/Icons/IconGoToArtist.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-export default function IconGoToArtist(props: React.SVGProps) {
- return (
-
-
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/IconPlus.tsx b/apps/web/components/Icons/IconPlus.tsx
deleted file mode 100644
index 6cc8f24d..00000000
--- a/apps/web/components/Icons/IconPlus.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function IconPlus(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/IconQueue.tsx b/apps/web/components/Icons/IconQueue.tsx
deleted file mode 100644
index 9207e7be..00000000
--- a/apps/web/components/Icons/IconQueue.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function IconQueue(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/Pause.tsx b/apps/web/components/Icons/Pause.tsx
deleted file mode 100644
index 0c061c6d..00000000
--- a/apps/web/components/Icons/Pause.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function IconPause(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/Play.tsx b/apps/web/components/Icons/Play.tsx
deleted file mode 100644
index d2b65570..00000000
--- a/apps/web/components/Icons/Play.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function IconPlay(props: React.SVGProps) {
- return (
-
-
-
- )
-}
diff --git a/apps/web/components/Icons/PlusCircle.tsx b/apps/web/components/Icons/PlusCircle.tsx
deleted file mode 100644
index b65991d7..00000000
--- a/apps/web/components/Icons/PlusCircle.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function PlusCircle(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/SpeakerWave.tsx b/apps/web/components/Icons/SpeakerWave.tsx
deleted file mode 100644
index 133801a6..00000000
--- a/apps/web/components/Icons/SpeakerWave.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function SpeakerWave(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Icons/SpeakerXMark.tsx b/apps/web/components/Icons/SpeakerXMark.tsx
deleted file mode 100644
index 1874abc0..00000000
--- a/apps/web/components/Icons/SpeakerXMark.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function SpeakerXMark(props: React.SVGProps) {
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/GenreButtons.tsx b/apps/web/components/Layout/GenreButtons.tsx
deleted file mode 100644
index 8393e5f9..00000000
--- a/apps/web/components/Layout/GenreButtons.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client"
-
-import { listAllGenres } from "@music/sdk"
-import { ScrollArea, ScrollBar } from "@music/ui/components/scroll-area"
-import { useState, useEffect, ReactNode } from "react"
-import ListenAgain from "../Home/ListenAgain"
-import RecommendedAlbums from "../Home/RecommendedAlbums"
-import RandomSongs from "../Home/RandomSongs"
-import FromYourLibrary from "../Home/FromYourLibrary"
-import MusicVideos from "../Home/MusicVideos"
-
-function capitalizeWords(str: string): string {
- return str.replace(/\b\w/g, char => char.toUpperCase())
-}
-
-interface GenreButtonsProps {
- children: ReactNode
-}
-
-export default function GenreButtons({ children }: GenreButtonsProps) {
- const [genres, setGenres] = useState([])
- const [selectedGenre, setSelectedGenre] = useState(null)
-
- useEffect(() => {
- async function fetchGenres() {
- let genreList = await listAllGenres()
- setGenres(genreList.slice(0, 10))
- }
-
- fetchGenres()
- }, [])
-
- const handleGenreClick = (genre: string) => {
- setSelectedGenre(prevGenre => (prevGenre === genre ? null : genre))
- }
-
- return (
- <>
-
-
- {genres.map((genre, index) => (
- handleGenreClick(genre)}
- >
- {capitalizeWords(genre)}
-
- ))}
-
-
-
- {selectedGenre && (
- <>
-
-
-
-
-
- >
- )}
- {!selectedGenre && children}
- >
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/Navbar.tsx b/apps/web/components/Layout/Navbar.tsx
deleted file mode 100644
index bd133eb4..00000000
--- a/apps/web/components/Layout/Navbar.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client"
-
-import { Button } from '@music/ui/components/button';
-import { Menu } from 'lucide-react';
-import Link from "next/link";
-import { useContext, useState } from 'react';
-import { LyricsContext } from '../Lyrics/LyricsOverlayContext';
-import { ScrollContext } from '../Providers/ScrollProvider';
-import { useSidebar } from '../Providers/SideBarProvider';
-import SearchBar from "../Search/SearchBar";
-import NavbarProfilePicture from '../User/NavbarProfilePicture';
-
-export default function NavBar() {
- const [isSearchActive, setIsSearchActive] = useState(false);
- const { onTopOfPage } = useContext(ScrollContext)
- const { areLyricsVisible, setLyricsVisible } = useContext(LyricsContext)
- const { toggleSidebar } = useSidebar()
-
- return (
-
-
-
-
-
-
setLyricsVisible(false)}>
-
- ParsonLabs Music
-
-
-
-
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/PageGradient.tsx b/apps/web/components/Layout/PageGradient.tsx
deleted file mode 100644
index 0080dc35..00000000
--- a/apps/web/components/Layout/PageGradient.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-"use client"
-
-import { useEffect } from "react";
-import { useGradientHover } from "../Providers/GradientHoverProvider";
-import { FastAverageColor } from "fast-average-color";
-
-export default function PageGradient({ imageSrc }: { imageSrc: string }) {
- const { setGradient } = useGradientHover()
-
- useEffect(() => {
- const fac = new FastAverageColor();
- const getColor = async () => {
- const color = await fac.getColorAsync(imageSrc)
- setGradient(color.hex)
- }
-
- getColor()
- }, [imageSrc, setGradient])
-
- return <>>
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/Playlists.tsx b/apps/web/components/Layout/Playlists.tsx
deleted file mode 100644
index 4110dd87..00000000
--- a/apps/web/components/Layout/Playlists.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { getPlaylist, getPlaylists, getUserInfoById } from "@music/sdk";
-import { Playlist as OriginalPlaylist } from "@music/sdk/types";
-import { Button } from "@music/ui/components/button";
-import { ListMusic, Plus } from "lucide-react";
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import CreatePlaylistDialog from "../Music/Playlist/CreatePlaylistDialog";
-
-interface Playlist extends OriginalPlaylist {
- users: string[];
-}
-
-export default function Playlists() {
- const [playlists, setPlaylists] = useState(null);
-
- useEffect(() => {
- async function fetchData() {
- const session = getSession();
- const playlistsData = await getPlaylists((Number(session?.sub)) ?? 0);
-
- const transformedPlaylists: Playlist[] = await Promise.all(
- playlistsData.map(async (playlist: any) => {
- const playlistInfo = await getPlaylist(playlist.id);
- const users = await Promise.all(
- playlistInfo.user_ids.map(async (userId: number) => {
- const userInfo = await getUserInfoById(userId);
- return userInfo.username;
- })
- );
- return {
- ...playlist,
- createdAt: new Date(playlist.createdAt),
- updatedAt: new Date(playlist.updatedAt),
- users: users,
- };
- })
- );
-
- setPlaylists(transformedPlaylists);
- }
- fetchData();
- }, []);
-
- return (
-
-
Playlists
- {playlists?.map(playlist => (
-
-
-
-
{playlist.name}
-
Playlist • {playlist.users.join(", ")}
-
-
- ))}
-
-
-
-
-
- Create Playlist
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/Settings/Sidebar.tsx b/apps/web/components/Layout/Settings/Sidebar.tsx
deleted file mode 100644
index 8311bc3b..00000000
--- a/apps/web/components/Layout/Settings/Sidebar.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession"
-import Link from "next/link"
-import { usePathname } from "next/navigation"
-import { useEffect, useState } from "react"
-
-export default function SettingsSidebar() {
- const session = getSession()
- const isAdmin = session?.role === "admin"
-
- const pathname = usePathname()
- const [currentPath, setCurrentPath] = useState(null)
-
- useEffect(() => {
- setCurrentPath(pathname)
- }, [pathname])
-
- if (currentPath === null) {
- return null
- }
-
- return (
-
- General
- {isAdmin && (
- <>
- Users
- Server
- Library
- >
- )}
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/Sidebar.tsx b/apps/web/components/Layout/Sidebar.tsx
deleted file mode 100644
index 708bc896..00000000
--- a/apps/web/components/Layout/Sidebar.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client"
-
-import { Button } from "@music/ui/components/button";
-import { HistoryIcon, Home } from "lucide-react";
-import Link from "next/link";
-import { ReactNode, useContext, useEffect, useState } from "react";
-import { LyricsContext } from "../Lyrics/LyricsOverlayContext";
-import { ScrollContext } from "../Providers/ScrollProvider";
-import { useSidebar } from "../Providers/SideBarProvider";
-import { usePathname } from "next/navigation";
-
-type SidebarProps = {
- children: ReactNode,
- sidebarContent: ReactNode
-}
-
-
-export default function Sidebar({ children, sidebarContent }: SidebarProps) {
- const { isOpen } = useSidebar();
- const { onTopOfPage } = useContext(ScrollContext);
- const { areLyricsVisible } = useContext(LyricsContext);
- const pathname = usePathname();
- const [currentPath, setCurrentPath] = useState(null);
-
- useEffect(() => {
- setCurrentPath(pathname);
- }, [pathname]);
-
- if (currentPath === null) {
- return null;
- }
-
- return (
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
- History
-
-
-
- {isOpen && sidebarContent}
-
-
-
- {children}
-
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/components/Layout/Sidebar/AdminPanellayout.tsx b/apps/web/components/Layout/Sidebar/AdminPanellayout.tsx
deleted file mode 100644
index ff8bf005..00000000
--- a/apps/web/components/Layout/Sidebar/AdminPanellayout.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-import { cn } from "@music/ui/lib/utils";
-import { useStore } from "./use-store";
-import Sidebar from "./Sidebar";
-import { useSidebarToggle } from "./use-sidebar-toggle";
-
-export default function AdminPanelLayout({
- children
-}: {
- children: React.ReactNode;
-}) {
- // const sidebar = useStore(useSidebarToggle, (state) => state);
-
- // if (!sidebar) return null;
- let sidebar = {
- isOpen: true
- }
-
- return (
- <>
-
-
- {children}
-
- >
- );
-}
diff --git a/apps/web/components/Layout/Sidebar/CollapseMenuButton.tsx b/apps/web/components/Layout/Sidebar/CollapseMenuButton.tsx
deleted file mode 100644
index a39cbe3a..00000000
--- a/apps/web/components/Layout/Sidebar/CollapseMenuButton.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useState } from "react";
-import { ChevronDown, Dot, LucideIcon } from "lucide-react";
-
-import { cn } from "@music/ui/lib/utils";
-import { Button } from "@music/ui/components/button";
-import { DropdownMenuArrow } from "@radix-ui/react-dropdown-menu";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger
-} from "@music/ui/components/collapsible";
-import {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
- TooltipProvider
-} from "@music/ui/components/tooltip";
-import {
- DropdownMenu,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuSeparator
-} from "@music/ui/components/dropdown-menu";
-
-type Submenu = {
- href: string;
- label: string;
- active: boolean;
-};
-
-interface CollapseMenuButtonProps {
- icon: LucideIcon;
- label: string;
- active: boolean;
- submenus: Submenu[];
- isOpen: boolean | undefined;
-}
-
-export function CollapseMenuButton({
- icon: Icon,
- label,
- active,
- submenus,
- isOpen
-}: CollapseMenuButtonProps) {
- const isSubmenuActive = submenus.some((submenu) => submenu.active);
- const [isCollapsed, setIsCollapsed] = useState(isSubmenuActive);
-
- return isOpen ? (
-
-
-
-
-
-
-
- {submenus.map(({ href, label, active }, index) => (
-
-
-
-
-
-
- {label}
-
-
-
- ))}
-
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
- {label}
-
-
-
-
-
- {label}
-
-
- {submenus.map(({ href, label }, index) => (
-
-
- {label}
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/components/Layout/Sidebar/Menu.tsx b/apps/web/components/Layout/Sidebar/Menu.tsx
deleted file mode 100644
index f40b2a80..00000000
--- a/apps/web/components/Layout/Sidebar/Menu.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { Ellipsis, LogOut } from "lucide-react";
-import { usePathname } from "next/navigation";
-
-import { cn } from "@music/ui/lib/utils";
-import { getMenuList } from "./menu-list";
-import { Button } from "@music/ui/components/button";
-import { ScrollArea } from "@music/ui/components/scroll-area";
-import { CollapseMenuButton } from "./CollapseMenuButton";
-import {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
- TooltipProvider
-} from "@music/ui/components/tooltip";
-
-interface MenuProps {
- isOpen: boolean | undefined;
-}
-
-export function Menu({ isOpen }: MenuProps) {
- const pathname = usePathname();
- const menuList = getMenuList(pathname);
-
- return (
-
-
-
- {menuList.map(({ groupLabel, menus }, index) => (
-
- {(isOpen && groupLabel) || isOpen === undefined ? (
-
- {groupLabel}
-
- ) : !isOpen && isOpen !== undefined && groupLabel ? (
-
-
-
-
-
-
-
-
- {groupLabel}
-
-
-
- ) : (
-
- )}
- {menus.map(
- ({ href, label, icon: Icon, active, submenus }, index) =>
- submenus.length === 0 ? (
-
-
-
-
-
-
-
-
-
-
- {label}
-
-
-
-
- {isOpen === false && (
-
- {label}
-
- )}
-
-
-
- ) : (
-
-
-
- )
- )}
-
- ))}
-
-
-
-
- {}}
- variant="outline"
- className="w-full justify-center h-10 mt-5"
- >
-
-
-
-
- Sign out
-
-
-
- {isOpen === false && (
- Sign out
- )}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/Layout/Sidebar/Sidebar.tsx b/apps/web/components/Layout/Sidebar/Sidebar.tsx
deleted file mode 100644
index da5233d1..00000000
--- a/apps/web/components/Layout/Sidebar/Sidebar.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import Link from "next/link";
-
-import pl from "@/assets/pl.png";
-import { Button } from "@music/ui/components/button";
-import { cn } from "@music/ui/lib/utils";
-import Image from "next/image";
-import { Menu } from "./Menu";
-import { SidebarToggle } from "./SidebarToggle";
-import { useSidebarToggle } from "./use-sidebar-toggle";
-
-export default function Sidebar() {
- const { isOpen, setIsOpen } = useSidebarToggle();
-
- if (isOpen === undefined) return null;
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Layout/Sidebar/SidebarProvider.tsx b/apps/web/components/Layout/Sidebar/SidebarProvider.tsx
deleted file mode 100644
index 657f925e..00000000
--- a/apps/web/components/Layout/Sidebar/SidebarProvider.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { createContext, useContext, ReactNode } from 'react';
-import { useStore } from './use-store';
-import { useSidebarToggle } from './use-sidebar-toggle';
-
-interface SidebarContextProps {
- isOpen: boolean;
- setIsOpen: (isOpen: boolean) => void;
-}
-
-const SidebarContext = createContext(undefined);
-
-export const useSidebar = () => {
- const context = useContext(SidebarContext);
- if (!context) {
- throw new Error('useSidebar must be used within a SidebarProvider');
- }
- return context;
-};
-
-interface SidebarProviderProps {
- children: ReactNode;
-}
-
-export const SidebarProvider = ({ children }: SidebarProviderProps) => {
- const sidebar = useStore(useSidebarToggle, (state) => state) as SidebarContextProps;
-
- if (!sidebar) return null;
-
- return (
-
- {children}
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/components/Layout/Sidebar/SidebarToggle.tsx b/apps/web/components/Layout/Sidebar/SidebarToggle.tsx
deleted file mode 100644
index e7518dc3..00000000
--- a/apps/web/components/Layout/Sidebar/SidebarToggle.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { ChevronLeft } from "lucide-react";
-
-import { cn } from "@music/ui/lib/utils";
-import { Button } from "@music/ui/components/button";
-
-interface SidebarToggleProps {
- isOpen: boolean | undefined;
- setIsOpen?: () => void;
-}
-
-export function SidebarToggle({ isOpen, setIsOpen }: SidebarToggleProps) {
- return (
-
- setIsOpen?.()}
- className="rounded-md w-8 h-8"
- variant="outline"
- size="icon"
- >
-
-
-
- );
-}
diff --git a/apps/web/components/Layout/Sidebar/menu-list.ts b/apps/web/components/Layout/Sidebar/menu-list.ts
deleted file mode 100644
index 6ad24cdd..00000000
--- a/apps/web/components/Layout/Sidebar/menu-list.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {
- Tag,
- Users,
- Settings,
- Bookmark,
- SquarePen,
- LayoutGrid,
- LucideIcon
-} from "lucide-react";
-
-type Submenu = {
- href: string;
- label: string;
- active: boolean;
-};
-
-type Menu = {
- href: string;
- label: string;
- active: boolean;
- icon: LucideIcon
- submenus: Submenu[];
-};
-
-type Group = {
- groupLabel: string;
- menus: Menu[];
-};
-
-export function getMenuList(pathname: string): Group[] {
- return [
- {
- groupLabel: "",
- menus: [
- {
- href: "/dashboard",
- label: "Dashboard",
- active: pathname.includes("/dashboard"),
- icon: LayoutGrid,
- submenus: []
- }
- ]
- },
- {
- groupLabel: "Access",
- menus: [
- {
- href: "/admin/users",
- label: "Users",
- active: pathname.includes("/users"),
- icon: Users,
- submenus: []
- },
- ]
- },
- ];
-}
diff --git a/apps/web/components/Layout/Sidebar/use-sidebar-toggle.ts b/apps/web/components/Layout/Sidebar/use-sidebar-toggle.ts
deleted file mode 100644
index be7894fa..00000000
--- a/apps/web/components/Layout/Sidebar/use-sidebar-toggle.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useEffect, useState } from 'react';
-
-interface useSidebarToggleStore {
- isOpen: boolean;
- setIsOpen: () => void;
-}
-
-export const useSidebarToggle = (): useSidebarToggleStore => {
- const [isOpen, setIsOpenState] = useState(() => {
- if (typeof window !== 'undefined') {
- const savedState = localStorage.getItem('sidebarOpen');
- return savedState !== null ? JSON.parse(savedState) : true;
- }
- return true;
- });
-
- useEffect(() => {
- if (typeof window !== 'undefined') {
- localStorage.setItem('sidebarOpen', JSON.stringify(isOpen));
- }
- }, [isOpen]);
-
- const setIsOpen = () => {
- setIsOpenState(prevState => !prevState);
- };
-
- return { isOpen, setIsOpen };
-};
\ No newline at end of file
diff --git a/apps/web/components/Layout/Sidebar/use-store.ts b/apps/web/components/Layout/Sidebar/use-store.ts
deleted file mode 100644
index b5be0691..00000000
--- a/apps/web/components/Layout/Sidebar/use-store.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useState, useEffect } from 'react';
-
-export const useStore = (
- store: (callback: (state: T) => unknown) => unknown,
- callback: (state: T) => F
-) => {
- const [data, setData] = useState();
-
- useEffect(() => {
- const result = store(callback) as F;
- setData(result);
- }, [store, callback]);
-
- return data;
-};
\ No newline at end of file
diff --git a/apps/web/components/Layout/SplashScreen.tsx b/apps/web/components/Layout/SplashScreen.tsx
deleted file mode 100644
index 6b904f60..00000000
--- a/apps/web/components/Layout/SplashScreen.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client"
-
-import pl from '@/assets/pl-tp.png';
-import getSession from '@/lib/Authentication/JWT/getSession';
-import { Loader2Icon } from 'lucide-react';
-import Image from 'next/image';
-import { useRouter } from 'next/navigation';
-import React, { useEffect, useState } from 'react';
-
-interface SplashScreenProps {
- children: React.ReactNode;
-}
-
-const setItemWithExpiry = (key: string, value: string, ttl: number) => {
- const now = new Date();
- const item = {
- value: value,
- expiry: now.getTime() + ttl,
- };
- localStorage.setItem(key, JSON.stringify(item));
-};
-
-const getItemWithExpiry = (key: string) => {
- const itemStr = localStorage.getItem(key);
- if (!itemStr) {
- return null;
- }
- const item = JSON.parse(itemStr);
- const now = new Date();
- if (now.getTime() > item.expiry) {
- localStorage.removeItem(key);
- return null;
- }
- return item.value;
-};
-
-const SplashScreen: React.FC = ({ children }) => {
- const [loading, setLoading] = useState(true);
- const { push } = useRouter();
-
- useEffect(() => {
- const checkServerUrl = async () => {
- const storedServer = localStorage.getItem("server");
- const response = await fetch(
- `${storedServer && JSON.parse(storedServer).local_address || window.location.origin}/api/s/server/info`
- );
-
- if (response.ok) {
- const session = getSession();
- if (session) {
- push("/home");
- setLoading(false);
- setItemWithExpiry("loading", "false", 3600000);
- } else {
- push("/login");
- setLoading(false);
- setItemWithExpiry("loading", "false", 3600000);
- }
- } else {
- setLoading(false);
- setItemWithExpiry("loading", "false", 3600000);
- }
- };
-
- checkServerUrl();
- }, [push]);
-
- useEffect(() => {
- const storedLoading = getItemWithExpiry("loading");
- if (storedLoading === "false") {
- setLoading(false);
- return;
- }
-
- }, [push]);
-
- if (loading) {
- return (
-
- );
- }
-
- return <>{children}>;
-};
-
-export default SplashScreen;
\ No newline at end of file
diff --git a/apps/web/components/Layout/TopGradient.tsx b/apps/web/components/Layout/TopGradient.tsx
deleted file mode 100644
index 9b6fe19e..00000000
--- a/apps/web/components/Layout/TopGradient.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-"use client"
-
-import { useGradientHover } from "../Providers/GradientHoverProvider"
-
-export default function TopBlur() {
- const { gradient } = useGradientHover()
-
- return (
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Lyrics/LyricsOverlay.tsx b/apps/web/components/Lyrics/LyricsOverlay.tsx
deleted file mode 100644
index 396a31b4..00000000
--- a/apps/web/components/Lyrics/LyricsOverlay.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-"use client";
-
-import { ScrollArea } from "@music/ui/components/scroll-area";
-import { FastAverageColor } from "fast-average-color";
-import Image from "next/image";
-import { useContext, useEffect, useRef, useState } from "react";
-import { usePlayer } from "../Music/Player/usePlayer";
-import { LyricsContext } from "./LyricsOverlayContext";
-
-type QueuePanelProps = {
- children: React.ReactNode;
-};
-
-export interface LyricsObjectResponse {
- id: number;
- name: string;
- trackName: string;
- artistName: string;
- albumName: string;
- duration: number;
- instrumental: boolean;
- plainLyrics: string;
- syncedLyrics?: string;
-}
-
-const parseLyrics = (lyrics: string) => {
- return lyrics.split("\n").map((line) => {
- const time = line.substring(line.indexOf("[") + 1, line.indexOf("]"));
- const text = line.substring(line.indexOf("]") + 1).trim();
- let minutes = 0,
- seconds = 0,
- hundredths = 0;
-
- if (time.includes(":")) {
- const timeParts = time.split(":");
- minutes = parseFloat(timeParts[0] ?? "0");
- if (timeParts[1]?.includes(".")) {
- const secondParts = timeParts[1].split(".");
- seconds = parseFloat(secondParts[0] ?? "0");
- hundredths = parseFloat(secondParts[1] ?? "0");
- } else {
- seconds = parseFloat(timeParts[1] ?? "0");
- }
- }
-
- const timeInSeconds = minutes * 60 + seconds + hundredths / 100;
- return { time: timeInSeconds, text };
- });
-};
-
-export default function LyricsOverlay({ children }: QueuePanelProps) {
- const { areLyricsVisible, setLyricsVisible, setCurrentLyrics } = useContext(LyricsContext);
- const { song, currentTime, handleTimeChange, imageSrc } = usePlayer();
- const [lyrics, setLyrics] = useState<{ time: number; text: string }[]>([]);
- const [currentLyric, setCurrentLyric] = useState("");
- const [isSyncedLyrics, setIsSyncedLyrics] = useState(false);
- const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
- const [isUserScrolling, setIsUserScrolling] = useState(false);
- const lyricsRef = useRef(null);
- const [scrollTimeoutID, setScrollTimeoutID] = useState(
- null
- );
- const [backgroundColour, setBackgroundColour] = useState("");
- const [noLyricMessage, setNoLyricMessage] = useState("");
-
- useEffect(() => {
- if (imageSrc) {
- const fac = new FastAverageColor();
- const getColor = async () => {
- const color = await fac.getColorAsync(imageSrc);
- setBackgroundColour(color.hex);
- };
- getColor();
- }
- }, [imageSrc]);
-
- useEffect(() => {
- if (!isUserScrolling && lyricsRef.current && currentLyricIndex >= 0) {
- const currentLyricElement = lyricsRef.current.querySelector(`:scope > div > p:nth-child(${currentLyricIndex + 1})`);
- if (currentLyricElement) {
- currentLyricElement.scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- }
- }
- }, [currentLyricIndex, isUserScrolling]);
-
- function handleScroll() {
- setIsUserScrolling(true);
- if (scrollTimeoutID) {
- clearTimeout(scrollTimeoutID);
- }
- setScrollTimeoutID(setTimeout(() => setIsUserScrolling(false), 100));
- }
-
- useEffect(() => {
- return () => {
- if (scrollTimeoutID) {
- clearTimeout(scrollTimeoutID);
- }
- };
- }, [scrollTimeoutID]);
-
- useEffect(() => {
- setLyrics([]);
- setCurrentLyric("");
- setCurrentLyricIndex(-1);
- setIsUserScrolling(false);
- setIsSyncedLyrics(false);
- setBackgroundColour("");
-
- const fetchLyrics = async () => {
- if (song.id) {
- const response = await fetch(
- `https://lrclib.net/api/search?q=${encodeURIComponent(`${song.name} ${song.artist}`)}`
- );
- const data: LyricsObjectResponse[] = await response.json();
- setCurrentLyrics(data[0]?.plainLyrics ?? "")
- if (data[0]?.syncedLyrics) {
- setLyrics(parseLyrics(data[0].syncedLyrics));
- setIsSyncedLyrics(true);
- } else if (data[0]?.plainLyrics) {
- setLyrics(parseLyrics(data[0].plainLyrics));
- setIsSyncedLyrics(false);
- }
- }
- };
-
- fetchLyrics();
- }, [song, setCurrentLyrics]);
-
- useEffect(() => {
- let animationFrameId: number;
-
- const updateLyric = () => {
- const currentSongTime = currentTime;
- const lineIndex = lyrics.findIndex((line, i) => {
- return (
- currentSongTime >= line.time &&
- (i + 1 === lyrics.length ||
- currentSongTime < (lyrics[i + 1]?.time ?? 0))
- );
- });
-
- if (lineIndex !== -1 && lyrics[lineIndex]?.text.length !== 0) {
- setCurrentLyric(lyrics[lineIndex]!.text);
- setCurrentLyricIndex(lineIndex);
- }
-
- animationFrameId = requestAnimationFrame(updateLyric);
- };
-
- updateLyric();
-
- return () => cancelAnimationFrame(animationFrameId);
- }, [lyrics, currentTime]);
-
-
- useEffect(() => {
- const noLyricMessages = [
- "Why do some songs have lyrics and others just have 'WOO!'s and 'YEAH!'s? It's like they're trying to confuse me on purpose",
- "Can someone please tell me where in the world of Google my favorite song's lyrics went? Did I accidentally delete the internet?",
- "I've got a master's degree in searching for lyrics, but somehow I still can't find this one...",
- "I've given up on finding the lyrics for that song. I've started making up my own.",
- "If finding lyrics was an Olympic sport, I'd be a bronze medalist at best... but hey, at least I'm consistent!",
- "I've searched so hard for those lyrics that I think I might actually have a direct line to the songwriters' email... or maybe I just really need to get out more.",
- "I'm starting to think that songwriters have a secret pact to make their lyrics impossible to find... like some kind of lyrical Illuminati.",
- "What if the only way to get the lyrics is to become a professional detective and solve a series of music-themed mysteries?",
- "I've checked every website, app, and meme page... but it seems even Google can't find those lyrics for me... I'm starting to think they're hiding in a parallel universe.",
- "I've checked every fan site, forum, and Reddit thread... but it seems even the most dedicated fans don't know what those lyrics say... I'm starting to think it's a conspiracy.",
- "I've scrolled through so many tabs that I think my browser has started to auto-complete lyrics for me... or maybe it's just trying to get rid of me.",
- "What's the deal with songwriters not just putting up a 'Lyrics Unavailable' sign like normal people do? Do they think we're all just going to wing it and make up our own words?",
- "Thanks for nothing, Genius.com, for telling me that I need to 'try harder' to find those lyrics... like it's my fault your algorithm is broken or something",
- "I'm trying to 'Run the World' but it's hard when I can't even get the lyrics right... thanks, Genius.com, for nothing."
- ];
- const randomNoLyricMessage = noLyricMessages[Math.floor(Math.random() * noLyricMessages.length)] ?? "";
- setNoLyricMessage(randomNoLyricMessage)
-
- }, [areLyricsVisible])
-
- return areLyricsVisible ? (
- <>
- {/*
*/}
-
-
-
-
- {lyrics.length > 0 ? (
- lyrics.map((line, index) => (
-
handleTimeChange(line.time) : undefined
- }
- >
- {line.text}
-
- ))
- ) : (
-
- {noLyricMessage}
-
- )}
-
-
-
- >
- ) : (
- children
- );
-}
diff --git a/apps/web/components/Lyrics/LyricsOverlayContext.tsx b/apps/web/components/Lyrics/LyricsOverlayContext.tsx
deleted file mode 100644
index 3206648a..00000000
--- a/apps/web/components/Lyrics/LyricsOverlayContext.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client"
-
-import { usePathname, useSearchParams } from 'next/navigation';
-import { createContext, Suspense, useEffect, useState } from 'react';
-
-export const LyricsContext = createContext({
- areLyricsVisible: false,
- setLyricsVisible: (visible: boolean) => {},
- toggleLyrics: () => {},
- currentLyrics: "",
- setCurrentLyrics: (lyrics: string) => {}
-});
-
-type PanelProviderProps = {
- children: React.ReactNode
-}
-
-function LyricsOverlayProvider({ children }: PanelProviderProps) {
- const [areLyricsVisible, setLyricsVisible] = useState(false);
- const [currentLyrics, setCurrentLyrics] = useState("");
- const pathName = usePathname()
-
- const toggleLyrics = () => {
- setLyricsVisible(prev => !prev);
- };
-
- return (
-
-
-
-
- {children}
-
- );
-};
-
-type SearchParamsComponentType = {
- setLyricsVisible: Function;
- pathName: string
-}
-
-function SearchParamsComponent({ setLyricsVisible, pathName }: SearchParamsComponentType) {
- const searchParams = useSearchParams()
-
- useEffect(() => {
- setLyricsVisible(false)
- }, [pathName, searchParams, setLyricsVisible])
-
- return null;
-}
-
-export default LyricsOverlayProvider;
\ No newline at end of file
diff --git a/apps/web/components/Music/Album/AlbumCard.tsx b/apps/web/components/Music/Album/AlbumCard.tsx
deleted file mode 100644
index 89a6f5f4..00000000
--- a/apps/web/components/Music/Album/AlbumCard.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Album, LibrarySong } from "@music/sdk/types";
-import Link from "next/link";
-
-type PlaylistCardProps = {
- song: LibrarySong,
- album: Album
-}
-
-export default function AlbumCard({ song, album }: PlaylistCardProps) {
- return (
-
-
-
e.stopPropagation()} href={`/album?id=${album.id}`}>
{song.name}
-
-
- )
-}
diff --git a/apps/web/components/Music/Album/AlbumTable.tsx b/apps/web/components/Music/Album/AlbumTable.tsx
deleted file mode 100644
index 2e239083..00000000
--- a/apps/web/components/Music/Album/AlbumTable.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { distance } from 'fastest-levenshtein';
-import { usePlayer } from "@/components/Music/Player/usePlayer";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { Album, Artist, LibrarySong } from "@music/sdk/types";
-import SongContextMenu from "../SongContextMenu";
-import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@music/ui/components/table";
-import { useEffect, useState } from 'react';
-import getBaseURL from '@/lib/Server/getBaseURL';
-import PlaylistCard from '../Playlist/PlaylistCard';
-
-type PlaylistTableProps = {
- songs: LibrarySong[]
- album: Album & { artist_object: Artist }
- artist: Artist
-}
-
-// Function to normalize strings (e.g., convert to lowercase, remove special characters)
-function normalizeString(str: string): string {
- return str.toLowerCase().replace(/[^a-z0-9\s]/g, '');
-}
-
-// Function to check similarity using Levenshtein distance
-function isSimilarLevenshtein(apiTrack: string, userTrack: string): boolean {
- const normalizedApiTrack = normalizeString(apiTrack);
- const normalizedUserTrack = normalizeString(userTrack);
- const levenshteinDistance = distance(normalizedApiTrack, normalizedUserTrack);
- const maxLength = Math.max(normalizedApiTrack.length, normalizedUserTrack.length);
- const similarity = 1 - (levenshteinDistance / maxLength);
- return similarity > 0.6; // Adjust the threshold as needed
-}
-
-export default function AlbumTable({ songs, album, artist }: PlaylistTableProps) {
- const { setImageSrc, setSong, setAudioSource, setArtist, setAlbum, addToQueue } = usePlayer();
- const [orderedSongs, setOrderedSongs] = useState([]);
-
- const session = getSession();
- const bitrate = session?.bitrate ?? 0;
-
- useEffect(() => {
- if (album.release_album) {
- const apiTracks = album.release_album.tracks.map(track => track.track_name);
- const userTracks = songs.map(song => song.name);
-
- const categorizedTracks = apiTracks.map(apiTrack => {
- const similarTracks = userTracks.filter(userTrack => isSimilarLevenshtein(apiTrack, userTrack));
- return {
- apiTrack,
- similarTracks
- };
- });
-
- const ordered: any[] = [];
- const remaining = [...songs];
-
- categorizedTracks.forEach(({ apiTrack, similarTracks }) => {
- similarTracks.forEach(similarTrack => {
- const song = songs.find(song => song.name === similarTrack);
- if (song) {
- ordered.push(song);
- const index = remaining.indexOf(song);
- if (index > -1) {
- remaining.splice(index, 1);
- }
- }
- });
- });
-
- setOrderedSongs([...ordered, ...remaining]);
- } else {
- setOrderedSongs(songs);
- }
- }, [songs, album]);
-
- const handlePlay = async (coverURL: string, song: LibrarySong, songURL: string, artist: Artist) => {
- setImageSrc(`${getBaseURL()}/image/${encodeURIComponent(album.cover_url)}`);
- setArtist(artist);
- setAlbum(album);
- setSong(song);
- setAudioSource(songURL);
-
- let track_number = song.track_number;
-
- const songsToQueue = album.songs.filter((s) => s.track_number >= track_number);
- songsToQueue.forEach((s) => {
- addToQueue(s, album, artist);
- });
- };
-
- function formatDuration(duration: number) {
- const minutes = Math.floor(duration / 60);
- const seconds = Math.round(duration % 60);
- return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
- };
-
- function sanitizeSongName(songName: string) {
- return songName.replace(/\s+/g, '_').replace(/[^\w-]+/g, '');
- }
-
- return (
-
-
-
-
- #
- Title
- Duration
-
-
-
- {orderedSongs.map(song => (
-
-
- handlePlay(album.cover_url, song, `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${bitrate}`, artist)}>
- {song.track_number}
-
-
-
- {formatDuration(song.duration)}
-
-
-
- ))}
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Music/Artist/ArtistCard.tsx b/apps/web/components/Music/Artist/ArtistCard.tsx
deleted file mode 100644
index d314f24c..00000000
--- a/apps/web/components/Music/Artist/ArtistCard.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import getBaseURL from "@/lib/Server/getBaseURL";
-import { Artist } from "@music/sdk/types";
-import Image from "next/image";
-import Link from "next/link";
-
-type ArtistCardProps = {
- artist: Artist
-}
-
-function formatFollowers(followers: number): string {
- if (followers >= 1000000) {
- return (followers / 1000000).toFixed(1) + 'M';
- } else if (followers >= 1000) {
- return (followers / 1000).toFixed(1) + 'K';
- } else {
- return followers.toString();
- }
-}
-
-export default async function ArtistCard({ artist }: ArtistCardProps) {
- const artistIconURL = artist.icon_url.length === 0 ? "/snf.png" : `${getBaseURL()}/image/${encodeURIComponent(artist.icon_url)}`
-
- return (
-
-
-
-
-
-
{artist.name}
-
{formatFollowers(artist.followers)} Followers
-
-
-
-
- )
-}
diff --git a/apps/web/components/Music/Card/Album/AlbumCard.tsx b/apps/web/components/Music/Card/Album/AlbumCard.tsx
deleted file mode 100644
index 60ffae2e..00000000
--- a/apps/web/components/Music/Card/Album/AlbumCard.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import getBaseURL from "@/lib/Server/getBaseURL";
-import { Album, Artist } from "@music/sdk/types";
-import Image from "next/image";
-import Link from "next/link";
-
-type AlbumCardProps = {
- artist: Artist
- album: Album
-}
-
-export default function AlbumCard({ artist, album }: AlbumCardProps) {
- const albumCoverURL = (!album.cover_url || album.cover_url.length === 0) ? "/snf.png" : `${getBaseURL()}/image/${encodeURIComponent(album.cover_url)} `
- let releaseDate = new Date(album.first_release_date).toLocaleString('default', { year: 'numeric' });
-
- return (
-
-
-
-
{album.name}
-
-
-
-
- {artist.name} • {releaseDate}
-
-
-
-
- )
-}
diff --git a/apps/web/components/Music/Card/BigCard.tsx b/apps/web/components/Music/Card/BigCard.tsx
deleted file mode 100644
index ed3fec0f..00000000
--- a/apps/web/components/Music/Card/BigCard.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client"
-
-import { useGradientHover } from "@/components/Providers/GradientHoverProvider"
-import { Album, Artist, LibrarySong } from "@music/sdk/types"
-import { FastAverageColor } from "fast-average-color"
-import Image from "next/image"
-import Link from "next/link"
-import { usePlayer } from "../Player/usePlayer"
-import SongContextMenu from "../SongContextMenu"
-
-type BigCardProps = {
- imageSrc: string,
- title: string,
- artist: Artist,
- songURL: string,
- albumURL: string
- type: string
- song: LibrarySong
- album: Album
-}
-
-export default function BigCard({ imageSrc, title, artist, songURL, albumURL, type, song, album }: BigCardProps) {
- const {
- setImageSrc,
- setAudioSource,
- setSong,
- setArtist,
- setAlbum
- } = usePlayer()
-
- function handlePlay() {
- setImageSrc(imageSrc)
- setArtist(artist)
- setAlbum(album)
- setSong(song)
- setAudioSource(songURL)
- }
-
-
- const { setGradient } = useGradientHover()
-
- function setDominantGradient() {
- const fac = new FastAverageColor();
- const getColor = async () => {
- const color = await fac.getColorAsync(imageSrc)
- setGradient(color.hex)
- }
- getColor()
- }
-
- return (
-
-
-
-
-
-
-
{title}
-
{type} • {artist.name}
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/Music/Card/HorizontalCard.tsx b/apps/web/components/Music/Card/HorizontalCard.tsx
deleted file mode 100644
index fce974d1..00000000
--- a/apps/web/components/Music/Card/HorizontalCard.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-
-import { useGradientHover } from "@/components/Providers/GradientHoverProvider";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import formatDuration from "@/lib/Formatting/formatDuration";
-import formatFollowers from "@/lib/Formatting/formatFollowers";
-import formatReleaseDate from "@/lib/Formatting/formatReleaseDate";
-import { getSongInfo } from "@music/sdk";
-import { AlbumInfo, ArtistInfo, CombinedItem } from "@music/sdk/types";
-import { FastAverageColor } from "fast-average-color";
-import Image from "next/image";
-import Link from "next/link";
-import { usePlayer } from "../Player/usePlayer";
-
-type HorizontalCardProps = {
- item: CombinedItem
-};
-
-export default function HorizontalCard({ item }: HorizontalCardProps) {
- const { item_type, name, artist_object, album_object, song_object } = item;
- const imagePath = album_object?.cover_url || artist_object?.icon_url || "";
- const session = getSession();
-
- const {
- setImageSrc,
- setAudioSource,
- setSong,
- setArtist,
- setAlbum
- } = usePlayer();
-
- const { setGradient } = useGradientHover();
-
- function setDominantGradient() {
- const fac = new FastAverageColor();
- const getColor = async () => {
- const color = await fac.getColorAsync(imageUrl ?? "");
- setGradient(color.hex);
- };
- getColor();
- }
-
- async function handlePlay() {
- setImageSrc(imageUrl);
-
- const song = await getSongInfo(item.id ?? "")
-
- setArtist(song.artist_object);
- setAlbum(song.album_object);
- setSong(song);
- session && setAudioSource(`${getBaseURL()}/api/stream/${encodeURIComponent(song?.path ?? "")}?bitrate=${session.bitrate ?? 0}`);
- }
-
- const imageUrl = imagePath ? `${getBaseURL()}/image/${encodeURIComponent(imagePath)}` : '/snf.png';
-
- return (
-
- item_type == "song" && handlePlay()}>
-
-
-
{name}
-
- {item_type == "song" && song_object && (
-
{artist_object?.name} • {album_object?.name} • {formatDuration(song_object.duration)}
- )}
- {item_type == "album" && album_object && (
-
Album • {artist_object?.name} • {formatReleaseDate(album_object?.first_release_date ?? "")}
- )}
- {item_type == "artist" && artist_object && (
-
Artist • {formatFollowers(artist_object?.followers ?? 0)} Followers
- )}
-
-
-
-
- );
-}
-
-function ConditionalLink({ children, artist, album, type }: { children: React.ReactNode, artist?: ArtistInfo, album?: AlbumInfo, type: string }) {
- if (type === "song") {
- return <>{children}>;
- }
-
- const href = type === "artist" && artist ? `/artist?id=${artist.id}` : type === "album" && album ? `/album?id=${album.id}` : '/';
- return (
-
- {children}
-
- );
-}
diff --git a/apps/web/components/Music/Card/MusicVideoCard.tsx b/apps/web/components/Music/Card/MusicVideoCard.tsx
deleted file mode 100644
index a7159a0a..00000000
--- a/apps/web/components/Music/Card/MusicVideoCard.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-"use client";
-
-import { getSongInfo } from "@music/sdk";
-import { LibrarySong, MusicVideoSong } from "@music/sdk/types";
-import Image from "next/image";
-import Link from "next/link";
-import { useEffect, useState } from "react";
-
-type MusicVideoCardProps = {
- song: MusicVideoSong
-};
-
-export default function MusicVideoCard({ song }: MusicVideoCardProps) {
- const [songInfo, setSongInfo] = useState()
- useEffect(() => {
- async function fetchSongInfo() {
- const fetchedSongInfo = await getSongInfo(song.id)
- setSongInfo(fetchedSongInfo)
- }
-
- fetchSongInfo()
- }, [song.id])
-
- if (!song || !song.music_video) return null
-
- const getVideoId = (url: string) => {
- const urlParams = new URLSearchParams(new URL(url).search);
- return urlParams.get("v");
- };
-
- const videoId = getVideoId(song.music_video.url);
- const thumbnailUrl = videoId ? `http://i3.ytimg.com/vi/${videoId}/hqdefault.jpg` : "";
-
- return (
- {}}>
-
-
-
-
-
-
- {song.name}
-
-
- {songInfo?.album_object.name} • {songInfo?.artist_object.name}
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Music/Card/Search/TopResultsCard.tsx b/apps/web/components/Music/Card/Search/TopResultsCard.tsx
deleted file mode 100644
index aa7684ee..00000000
--- a/apps/web/components/Music/Card/Search/TopResultsCard.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import getSession from '@/lib/Authentication/JWT/getSession';
-import getBaseURL from "@/lib/Server/getBaseURL";
-import { getAlbumInfo, getArtistInfo, getSongInfo, LibraryAlbum } from '@music/sdk';
-import { Artist, CombinedItem, LibrarySong } from '@music/sdk/types';
-import Image from 'next/image';
-import { useEffect, useState } from 'react';
-import ArtistCard from '../../Artist/ArtistCard';
-import AlbumCard from '../Album/AlbumCard';
-import BigCard from '../BigCard';
-
-type ResultCardProps = {
- result: CombinedItem
-}
-
-export default function TopResultsCard({ result }: ResultCardProps) {
- const session = getSession()
-
- const [artist, setArtist] = useState(null)
- const [album, setAlbum] = useState(null)
- const [song, setSong] = useState(null)
-
- useEffect(() => {
- async function fetchInfo() {
- if (result.item_type === "song") {
- const song = await getSongInfo(result.id)
- setSong(song)
- }
-
- if (result.item_type === "album") {
- const album = await getAlbumInfo(result.id)
- setAlbum(album)
- }
-
- if (result.item_type === "artist") {
- const artist = await getArtistInfo(result.id)
- setArtist(artist)
- }
- }
-
- fetchInfo()
- }, [result.id, result.item_type])
-
-const coverUrl = result.item_type === "song"
- ? song?.album_object.cover_url
- : result.item_type === "album"
- ? album?.cover_url
- : result.item_type === "artist"
- ? artist?.icon_url
- : "";
-
- const imageSrc = `${getBaseURL()}/image/${encodeURIComponent(coverUrl || "")}?raw=true`;
-
- if (result.item_type === 'song' && song) {
- return (
-
- );
- } else if (result.item_type === 'artist' && artist) {
- return (
-
- );
- } else if (result.item_type === 'album' && album) {
- return (
-
- );
- }
-}
diff --git a/apps/web/components/Music/Player.tsx b/apps/web/components/Music/Player.tsx
deleted file mode 100644
index b577ea50..00000000
--- a/apps/web/components/Music/Player.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-"use client"
-
-import Image from "next/image";
-
-import {
- Slider,
- SliderRange,
- SliderThumb,
- SliderTrack,
-} from "@music/ui/components/slider";
-import { FastAverageColor } from "fast-average-color";
-import { BookAudioIcon, MicVocal, SkipBack, SkipForward } from "lucide-react";
-import Link from "next/link";
-import { useContext, useEffect, useRef, useState } from "react";
-import { AIContext } from "../AI/AIOverlayContext";
-import ArrowPath from "../Icons/ArrowPath";
-import IconQueue from "../Icons/IconQueue";
-import IconPause from "../Icons/Pause";
-import IconPlay from "../Icons/Play";
-import SpeakerWave from "../Icons/SpeakerWave";
-import SpeakerXMark from "../Icons/SpeakerXMark";
-import { LyricsContext } from "../Lyrics/LyricsOverlayContext";
-import { useGradientHover } from "../Providers/GradientHoverProvider";
-import { usePlayer } from "./Player/usePlayer";
-import VideoPlayerDialog from "./Player/VideoPlayerDialog";
-import { PanelContext } from "./Queue/QueuePanelContext";
-
-export default function Player() {
- const audioRef = useRef(null);
-
- const [liked, setLiked] = useState(false);
-
- const { togglePanel } = useContext(PanelContext);
- const { toggleLyrics } = useContext(LyricsContext);
- const { toggleAI, isAIVisible } = useContext(AIContext)
-
- const {
- isPlaying,
- onLoop,
- volume,
- currentTime,
- duration,
- togglePlayPause,
- toggleLoopSong,
- muted,
- setAudioVolume,
- handleTimeChange,
- handleTimeUpdate,
- toggleMute,
- imageSrc,
- playNextSong,
- playPreviousSong,
- song,
- bufferedTime,
- artist,
- album,
- } = usePlayer();
-
- const formatTime = (time: number) => {
- const minutes = Math.floor(time / 60);
- const seconds = Math.floor(time % 60);
- return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
- };
-
- useEffect(() => {
- if (isPlaying) document.title = `${song.name} | ParsonLabs Music`;
- if (!isPlaying) document.title = `ParsonLabs Music`
- }, [song, artist, isPlaying]);
-
- const { gradient } = useGradientHover()
-
- const [backgroundColour, setBackgroundColour] = useState("")
-
- useEffect(() => {
- if(imageSrc) {
- const fac = new FastAverageColor();
- const getColor = async () => {
- const color = await fac.getColorAsync(imageSrc);
- setBackgroundColour(color.hex);
- };
- getColor();
- }
- }, [imageSrc]);
-
- return song.id && (
-
- );
-}
diff --git a/apps/web/components/Music/Player/AddToPlaylistDropdown.tsx b/apps/web/components/Music/Player/AddToPlaylistDropdown.tsx
deleted file mode 100644
index 0df3d69b..00000000
--- a/apps/web/components/Music/Player/AddToPlaylistDropdown.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-"use client"
-
-
-import CheckCircle from "@/components/Icons/CheckCircle"
-import getSession from "@/lib/Authentication/JWT/getSession"
-import { addSongToPlaylist, getPlaylist, getPlaylists, getSongInfo } from "@music/sdk"
-import { Playlist as BasePlaylist, LibrarySong } from "@music/sdk/types"
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "@music/ui/components/dropdown-menu"
-import { ScrollArea } from "@music/ui/components/scroll-area"
-import { CircleDashed } from "lucide-react"
-import { useEffect, useState } from "react"
-import { usePlayer } from "./usePlayer"
-
-type AddToPlaylistDropdownProps = {
- children: React.ReactNode;
-};
-
-type SongInfo = {
- song_id: string;
- date_added: string;
-};
-
-type PlaylistWithSongs = BasePlaylist & {
- songs: (LibrarySong & { date_added: string })[];
- song_infos: SongInfo[];
- user_ids: number[];
-};
-
-export default function AddToPlaylistDropdown({ children }: AddToPlaylistDropdownProps) {
- const { song, isPlaying } = usePlayer();
-
- const [playlists, setPlaylists] = useState([]);
- const [playlistAddedNow, setPlaylistAddedNow] = useState("");
-
- useEffect(() => {
- const getPlaylistsRequest = async () => {
- const sessionData = getSession();
- if (sessionData) {
- const playlistsResponse = await getPlaylists(Number(sessionData.sub));
- const detailedPlaylistsPromises = playlistsResponse.map(playlist => getPlaylist(playlist.id));
- const detailedPlaylists = await Promise.all(detailedPlaylistsPromises);
-
- const songsDetailsPromises = detailedPlaylists.flatMap(playlist =>
- playlist.song_infos.map(songInfo => getSongInfo(String(songInfo.song_id)).then(songDetails => ({
- ...songDetails,
- date_added: songInfo.date_added
- })))
- );
-
- const songsDetails = await Promise.all(songsDetailsPromises);
-
- const playlistsWithSongs = detailedPlaylists.map((playlist, index: number) => ({
- ...playlist,
- createdAt: new Date(playlist.created_at),
- updatedAt: new Date(playlist.updated_at),
- songs: songsDetails.slice(index * playlist.song_infos.length, (index + 1) * playlist.song_infos.length) as (LibrarySong & { date_added: string })[],
- }));
-
- setPlaylists(playlistsWithSongs as PlaylistWithSongs[]);
- }
- };
-
- if (isPlaying) {
- getPlaylistsRequest();
- }
- }, [isPlaying]);
-
- return (
-
-
- {children}
-
-
-
- Add to Playlist
-
-
- {playlists && playlists.map((playlist) => (
- {
- addSongToPlaylist(playlist.id, song.id)
- setPlaylistAddedNow(String(playlist.id))
- }}>
- {playlist.name}
-
- {/* Converting to Strings due to JS' precision limits for numbers. Numbers between -(2^53 - 1) and 2^53 - 1; Any number outside this range can lose precision. The Song ID's are larger than 2^53 -1 (9e15)*/}
- {playlist.songs.some(playlistSong => String(playlistSong.id) === String(song.id)) || String(playlist.id) === playlistAddedNow ? : }
-
-
- ))}
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Music/Player/VideoPlayerDialog.tsx b/apps/web/components/Music/Player/VideoPlayerDialog.tsx
deleted file mode 100644
index 30461969..00000000
--- a/apps/web/components/Music/Player/VideoPlayerDialog.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import {
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger
-} from "@music/ui/components/dialog";
-import { Video } from "lucide-react";
-import { useEffect, useRef, useState } from 'react';
-import ReactPlayer from 'react-player/youtube';
-import { usePlayer } from './usePlayer';
-
-export type VideoPlayerDialogProps = {
- url: string | undefined;
-};
-
-export default function VideoPlayerDialog({ url }: VideoPlayerDialogProps) {
- const { isPlaying, togglePlayPause, song, currentTime, handleTimeChange } = usePlayer();
- const [isDialogOpen, setIsDialogOpen] = useState(false);
- const videoTimeRef = useRef(currentTime);
- const playerRef = useRef(null);
- const hasUpdatedTimeRef = useRef(false);
-
- useEffect(() => {
- if (isPlaying && isDialogOpen) {
- togglePlayPause();
- }
- }, [isPlaying, isDialogOpen, togglePlayPause]);
-
- useEffect(() => {
- if (isDialogOpen && playerRef.current) {
- playerRef.current.seekTo(currentTime, 'seconds');
- hasUpdatedTimeRef.current = true;
- }
- }, [isDialogOpen, currentTime]);
-
- useEffect(() => {
- if (!isDialogOpen && hasUpdatedTimeRef.current) {
- handleTimeChange(videoTimeRef.current.toString());
- hasUpdatedTimeRef.current = false;
- }
- }, [isDialogOpen, handleTimeChange]);
-
- const handleReady = () => {
- if (playerRef.current) {
- playerRef.current.seekTo(videoTimeRef.current, 'seconds');
- playerRef.current.getInternalPlayer().playVideo();
- }
- };
-
- const handleProgress = ({ playedSeconds }: { playedSeconds: number }) => {
- videoTimeRef.current = playedSeconds;
- };
-
- return (
-
-
-
-
-
-
-
-
- {song.name} Music Video
-
-
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Music/Player/usePlayer.tsx b/apps/web/components/Music/Player/usePlayer.tsx
deleted file mode 100644
index 2799edbb..00000000
--- a/apps/web/components/Music/Player/usePlayer.tsx
+++ /dev/null
@@ -1,545 +0,0 @@
-"use client";
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useRef,
- useState,
-} from "react";
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import {
- addSongToListenHistory,
- getRandomSong,
- getSongInfo,
- getSongsByGenres,
- setNowPlaying,
-} from "@music/sdk";
-import { Album, Artist, Genre, LibrarySong } from "@music/sdk/types";
-
-const isBrowser = typeof window !== "undefined";
-const audioElement = isBrowser ? new Audio() : null;
-
-type PlayerContextType = {
- playAudioSource: Function;
- isPlaying: boolean;
- onLoop: boolean;
- volume: number;
- muted: boolean;
- currentTime: number;
- duration: number;
- togglePlayPause: Function;
- toggleLoopSong: Function;
- playNextSong: Function;
- playPreviousSong: Function;
- setAudioVolume: Function;
- handleTimeChange: Function;
- handleTimeUpdate: Function;
- toggleMute: Function;
- setAudioSource: Function;
- setSong: Function;
- setArtist: Function;
- setAlbum: Function;
- setSongCallback: Function;
- setImageSrc: Function;
- setQueue: Function;
- addToQueue: Function;
- setBufferedTime: Function;
- bufferedTime: number;
- imageSrc: string;
- song: LibrarySong;
- artist: Artist;
- album: Album;
- queue: Queue[];
-};
-
-type Queue = {
- song: LibrarySong;
- album: Album;
- artist: Artist;
-};
-
-const PlayerContext = createContext(null);
-
-interface PlayerProviderProps {
- children: React.ReactNode;
-}
-
-export function PlayerProvider({ children }: PlayerProviderProps) {
- const audioRef = useRef(audioElement);
- const audio = audioRef.current as HTMLAudioElement;
-
- const [imageSrc, setImageSrc] = useState("");
- const [audioSource, setAudioSource] = useState("");
- const [song, setSong] = useState({
- artist: "",
- contributing_artists: [],
- name: "",
- path: "",
- track_number: 0,
- id: "",
- duration: 0,
- artist_object: {
- albums: [],
- description: "",
- followers: 0,
- icon_url: "",
- id: "",
- name: "",
- },
- album_object: {
- cover_url: "",
- description: "",
- first_release_date: "",
- id: "",
- musicbrainz_id: "",
- name: "",
- primary_type: "",
- songs: [],
- wikidata_id: "",
- },
- });
- const [artist, setArtist] = useState({
- albums: [],
- id: "",
- name: "",
- followers: 0,
- icon_url: "",
- description: "",
- });
- const [album, setAlbum] = useState({
- cover_url: "",
- id: "",
- name: "",
- description: "",
- first_release_date: "",
- musicbrainz_id: "",
- primary_type: "",
- wikidata_id: "",
- songs: [],
- });
-
- const [isPlaying, setIsPlaying] = useState(false);
- const [queue, setQueueState] = useState([]);
-
- const queueRef = useRef([]);
-
- const [currentSongIndex, setCurrentSongIndex] = useState(0);
- const [onLoop, setOnLoop] = useState(false);
- const [muted, setMuted] = useState(false);
- const [volume, setVolume] = useState(100);
- const [currentTime, setCurrentTime] = useState(0);
- const [duration, setDuration] = useState(0);
- const [base64Image, setBase64Image] = useState("");
- const [bufferedTime, setBufferedTime] = useState(0);
-
- const lastAddedSongIdRef = useRef(null);
-
- let bitrate = 0;
-
- const session = getSession();
-
- useEffect(() => {
- if (song.id && lastAddedSongIdRef.current != String(song.id)) {
- if (session) {
- setNowPlaying(Number(session.sub), String(song.id));
- addSongToListenHistory(Number(session.sub), String(song.id));
-
- lastAddedSongIdRef.current = String(song.id);
- }
- }
- }, [song.id, session]);
-
- const playAudioSource = useCallback(() => {
- if (audio) {
- audio.pause();
- audio.currentTime = 0;
- audio.src = audioSource;
- audio.oncanplaythrough = () => {
- audio.play();
- setIsPlaying(true);
- };
- }
- }, [audioSource, audio]);
-
- const setQueue = useCallback((newQueue: Queue[]) => {
- queueRef.current = newQueue;
- setQueueState(newQueue);
- }, []);
-
- const setSongCallback = useCallback(
- async (song: LibrarySong, artist: Artist, album: Album) => {
- setSong(song);
- setArtist(artist);
- setAlbum(album);
- if (album.cover_url.length === 0) {
- setBase64Image("/snf.png");
- setImageSrc("/snf.png");
- } else {
- // imageToBase64(album.cover_url).then((base64) => {
- // const base64Data = `data:image/jpg;base64,${base64}`;
- // setBase64Image(base64Data);
- // });
- setImageSrc(
- `${getBaseURL()}/image/${encodeURIComponent(album.cover_url)}`
- );
- }
- setAudioSource(
- `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${bitrate}`
- );
-
- const index = queueRef.current.findIndex((q) => q.song.id === song.id);
- if (index !== -1) {
- setCurrentSongIndex(index);
- }
- },
- [setSong, bitrate]
- );
-
- const playNextSong = useCallback(async () => {
- let nextSongIndex = currentSongIndex + 1;
- if (nextSongIndex >= queueRef.current.length) {
- nextSongIndex = 0;
- }
- setCurrentSongIndex(nextSongIndex);
- const next = queueRef.current[nextSongIndex];
-
- if (next) {
- const { song: nextSong, artist: nextArtist, album: nextAlbum } = next;
-
- if (nextSong) {
- setSongCallback(nextSong, nextArtist, nextAlbum);
- playAudioSource();
- }
- } else {
- let songGenres: Genre[] = [];
- if (song.album_object?.release_album?.genres) {
- songGenres = song.album_object.release_album.genres;
- } else if (song.album_object?.release_group_album?.genres) {
- songGenres = song.album_object.release_group_album.genres;
- }
-
- let recommendedSong = null;
- for (let i = 0; i < songGenres.length; i++) {
- const genre = songGenres[i];
- if (genre?.name) {
- const recommendedSongs = await getSongsByGenres([genre.name]);
- const shuffledRecommendedSongs = [...recommendedSongs];
-
- for (let i = shuffledRecommendedSongs.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [
- shuffledRecommendedSongs[i] as any,
- shuffledRecommendedSongs[j] as any,
- ] = [shuffledRecommendedSongs[j], shuffledRecommendedSongs[i]];
- }
-
- for (let shuffledSong of shuffledRecommendedSongs) {
- if (shuffledSong.name !== song.name) {
- recommendedSong = await getSongInfo(shuffledSong.id);
- break;
- }
- }
-
- if (recommendedSong) break;
- }
- }
-
- if (recommendedSong) {
- const {
- artist_object: recommendedArtist,
- album_object: recommendedAlbum,
- } = recommendedSong;
- setSongCallback(recommendedSong, recommendedArtist, recommendedAlbum);
- playAudioSource();
- } else {
- const randomSongs = await getRandomSong(1);
- if (randomSongs.length > 0) {
- const randomSong = randomSongs[0];
- if (randomSong) {
- setSongCallback(
- randomSong,
- randomSong.artist_object,
- randomSong.album_object
- );
- playAudioSource();
- }
- }
- }
- }
- }, [
- currentSongIndex,
- setSongCallback,
- playAudioSource,
- song.album_object?.release_album?.genres,
- song.album_object?.release_group_album?.genres,
- song.name,
- ]);
-
- const playPreviousSong = useCallback(() => {
- let previousSongIndex = currentSongIndex - 1;
- if (previousSongIndex < 0) {
- previousSongIndex = queueRef.current.length - 1;
- }
- setCurrentSongIndex(previousSongIndex);
- const previous = queueRef.current[previousSongIndex];
- if (previous) {
- const previousSong = previous.song;
- const previousArtist = previous.artist;
- const previousAlbum = previous.album;
-
- if (previousSong) {
- setSongCallback(previousSong, previousArtist, previousAlbum);
- playAudioSource();
- }
- }
- }, [currentSongIndex, setSongCallback, playAudioSource]);
-
- useEffect(() => {
- if (audioRef.current) {
- audioRef.current.src = audioSource;
- }
-
- if (audio) {
- audio.src = audioSource;
- audio.load();
- audio.oncanplaythrough = () => {
- audio.play();
- setIsPlaying(true);
- };
- audio.addEventListener("ended", playNextSong);
-
- if ("mediaSession" in navigator) {
- // Normalizing the path is necessary to remove the Windows long path prefix ("\\?\") if present.
- // This prefix allows Windows applications to handle paths longer than the MAX_PATH limit (260 characters),
- // but it's not recognized by web browsers or servers. Removing it ensures the path can be used in URLs
- // More Here:
- // https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry
- let normalisedAlbumPath = album.cover_url.startsWith("\\\\?\\")
- ? album.cover_url.substring(4)
- : album.cover_url;
- let albumPath = `${getBaseURL()}/image/${encodeURIComponent(normalisedAlbumPath)}?raw=true`;
-
- navigator.mediaSession.metadata = new MediaMetadata({
- title: song.name,
- artist: song.artist,
- album: album.name,
- artwork: [{ src: albumPath, type: "image/jpeg" }],
- });
- navigator.mediaSession.setActionHandler("play", () => audio.play());
- navigator.mediaSession.setActionHandler("pause", () => audio.pause());
- navigator.mediaSession.setActionHandler(
- "previoustrack",
- playPreviousSong
- );
- navigator.mediaSession.setActionHandler("nexttrack", playNextSong);
- }
- }
-
- return () => {
- audio.removeEventListener("ended", playNextSong);
- };
- }, [
- audioSource,
- audio,
- song,
- playNextSong,
- album.name,
- imageSrc,
- playPreviousSong,
- album.cover_url,
- ]);
-
- const removeFromQueue = useCallback((index: number) => {
- const newQueue = [...queueRef.current];
- newQueue.splice(index, 1);
- queueRef.current = newQueue;
- setQueueState(newQueue);
- }, []);
-
- const addToQueue = useCallback(
- (song: LibrarySong, album: Album, artist: Artist) => {
- const newQueue = [...queueRef.current, { song, album, artist }];
- queueRef.current = newQueue;
- setQueueState(newQueue);
- },
- []
- );
-
- const toggleMute = useCallback(() => {
- const audio = audioRef.current || new Audio();
- audio.muted = !muted;
- setMuted(!muted);
- }, [muted]);
-
- const togglePlayPause = useCallback(() => {
- const audio = audioRef.current;
- if (audio) {
- if (isPlaying) {
- audio.pause();
- } else {
- audio.play();
- }
- setIsPlaying(!isPlaying);
- }
- }, [isPlaying]);
-
- const toggleLoopSong = useCallback(() => {
- const audio = audioRef.current;
- if (audio) {
- audio.loop = !onLoop;
- setOnLoop(!onLoop);
- }
- }, [onLoop]);
-
- const setAudioVolume = useCallback((value: string) => {
- let volume = parseFloat(value) / 100;
- const audio = audioRef.current;
- if (audio) {
- audio.volume = volume;
- setVolume(volume);
- }
- }, []);
-
- const handleTimeChange = useCallback((value: string) => {
- let newTime = parseFloat(value);
- const audio = audioRef.current;
- if (audio) {
- audio.currentTime = newTime;
- setCurrentTime(newTime);
- }
- }, []);
-
- const handleTimeUpdate = useCallback(() => {
- const audio = audioRef.current;
- if (audio) {
- setCurrentTime(audio.currentTime);
- setDuration(audio.duration);
- const buffer = audio.buffered;
- if (buffer && buffer.length > 0) {
- setBufferedTime(buffer.end(buffer.length - 1));
- }
- }
- }, []);
-
- const handleTimeUpdateThrottled = useCallback(() => {
- setTimeout(() => {
- handleTimeUpdate();
- }, 1000);
- }, [handleTimeUpdate]);
-
- useEffect(() => {
- function stepForward() {
- setTimeout(() => {
- audio.currentTime += 1;
- }, 200);
- }
- function stepBack() {
- setTimeout(() => {
- audio.currentTime -= 1;
- }, 200);
- }
-
- if (audio) {
- audio.addEventListener("timeupdate", handleTimeUpdate);
-
- const handleKeyPress = (event: KeyboardEvent) => {
- const target = event.target as HTMLElement;
- if (target.tagName.toLowerCase() === "input") {
- return;
- }
- switch (event.key.toLocaleLowerCase()) {
- case " ":
- togglePlayPause();
- break;
- case "m":
- toggleMute();
- break;
- case "l":
- toggleLoopSong();
- break;
- }
- };
-
- const handleKeyUp = (event: KeyboardEvent) => {
- if ((event.target as HTMLElement).tagName.toLowerCase() === "input") {
- return;
- }
- switch (event.key.toLowerCase()) {
- case "arrowleft":
- stepBack();
- break;
- case "arrowright":
- stepForward();
- break;
- }
- };
-
- document.addEventListener("keydown", handleKeyPress);
- document.addEventListener("keyup", handleKeyUp);
-
- return () => {
- audio.removeEventListener("timeupdate", handleTimeUpdateThrottled);
- document.removeEventListener("keydown", handleKeyPress);
- document.removeEventListener("keyup", handleKeyUp);
- };
- }
- }, [
- handleTimeUpdate,
- handleTimeUpdateThrottled,
- toggleLoopSong,
- toggleMute,
- togglePlayPause,
- audio,
- ]);
-
- return (
-
- {children}
-
- );
-}
-
-export function usePlayer() {
- const context = useContext(PlayerContext);
- if (context === null) {
- throw new Error("usePlayer must be used within a PlayerProvider");
- }
- return context;
-}
diff --git a/apps/web/components/Music/Playlist/CreatePlaylistDialog.tsx b/apps/web/components/Music/Playlist/CreatePlaylistDialog.tsx
deleted file mode 100644
index a2973261..00000000
--- a/apps/web/components/Music/Playlist/CreatePlaylistDialog.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import getSession from "@/lib/Authentication/JWT/getSession"
-import { createPlaylist } from "@music/sdk"
-import { Button } from "@music/ui/components/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@music/ui/components/dialog"
-import { Input } from "@music/ui/components/input"
-import { Label } from "@music/ui/components/label"
-import { Loader2 } from "lucide-react"
-import { useState, useTransition } from "react"
-
-type CreatePlaylistDialog = {
- children: React.ReactNode
-}
-
-export default function CreatePlaylistDialog({ children }: CreatePlaylistDialog) {
- const [playlistName, setPlaylistName] = useState("")
- const [openDialog, setOpenDialog] = useState(false)
- const [isPending, startTransition] = useTransition()
-
- const session = getSession()
- const username = session?.username
-
- const open = () => setOpenDialog(true)
- const close = () => setOpenDialog(false)
-
- return (
- setOpenDialog(!openDialog)}>
-
- {children}
-
-
-
- Create Playlist
-
- Enter the playlist name to organise your music library.
-
-
-
-
-
- Name
-
- setPlaylistName(e.target.value)}
- className="col-span-3"
- />
-
-
-
- createPlaylist(Number(session?.sub), playlistName).then(close)}>
- {isPending && }
- Create Playlist
-
-
-
-
- )
-}
diff --git a/apps/web/components/Music/Playlist/DeletePlaylistButton.tsx b/apps/web/components/Music/Playlist/DeletePlaylistButton.tsx
deleted file mode 100644
index 21b07828..00000000
--- a/apps/web/components/Music/Playlist/DeletePlaylistButton.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client";
-
-import { deletePlaylist } from "@music/sdk";
-import { Button } from "@music/ui/components/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@music/ui/components/dialog";
-import { Loader2, X } from "lucide-react";
-import { useTransition } from "react";
-
-type DeletePlaylistButtonProps = {
- playlistID: string;
-};
-
-export default function DeletePlaylistButton({
- playlistID,
-}: DeletePlaylistButtonProps) {
- const [isPending, startTransition] = useTransition();
-
- return (
-
-
-
-
-
-
-
-
- Are you absolutely sure?
-
- This action will delete your playlist!
-
-
-
-
-
- Close
-
-
- deletePlaylist(Number(playlistID))}
- >
- {isPending && }
- Confirm
-
-
-
-
- );
-}
diff --git a/apps/web/components/Music/Playlist/PlaylistCard.tsx b/apps/web/components/Music/Playlist/PlaylistCard.tsx
deleted file mode 100644
index d143e6ab..00000000
--- a/apps/web/components/Music/Playlist/PlaylistCard.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import getBaseURL from "@/lib/Server/getBaseURL";
-import { Album, Artist, LibrarySong } from "@music/sdk/types";
-import Image from "next/image";
-import Link from "next/link";
-
-type PlaylistCardProps = {
- song: LibrarySong,
- coverURL: string
- artist: Artist
- album: Album
-}
-
-export default function PlaylistCard({ song, coverURL, artist, album }: PlaylistCardProps) {
- return (
-
-
-
-
e.stopPropagation()} href={`/album?id=${album.id}`}>
{song.name}
-
e.stopPropagation()} href={`/artist?id=${artist.id}`}>
{song.artist}
-
-
- )
-}
diff --git a/apps/web/components/Music/Playlist/PlaylistTable.tsx b/apps/web/components/Music/Playlist/PlaylistTable.tsx
deleted file mode 100644
index 985fbf71..00000000
--- a/apps/web/components/Music/Playlist/PlaylistTable.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-
-import { usePlayer } from "@/components/Music/Player/usePlayer";
-import PlaylistCard from "@/components/Music/Playlist/PlaylistCard";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { Album, Artist, LibrarySong, Song } from "@music/sdk/types";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
-} from "@music/ui/components/table";
-import Link from "next/link";
-import SongContextMenu from "../SongContextMenu";
-
-type PlaylistTableProps = {
- songsWithMetadata: (LibrarySong & { date_added: string })[];
-}
-
-export default function PlaylistTable({ songsWithMetadata }: PlaylistTableProps) {
- const { setImageSrc, setSong, setArtist, setAudioSource, setAlbum } = usePlayer()
-
- const session = getSession()
-
- const handlePlay = async (coverURL: string, song: LibrarySong, songURL: string, artist: Artist, album: Album) => {
- setImageSrc(`${getBaseURL()}/image/${encodeURIComponent(coverURL)}`)
- setArtist(artist)
- setAlbum(album)
- setSong(song)
- setAudioSource(songURL)
- }
-
- function formatDuration(duration: number) {
- const minutes = Math.floor(duration / 60);
- const seconds = Math.round(duration % 60);
- return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
- };
-
- const sortedSongs = [...songsWithMetadata].sort((a, b) => new Date(b.date_added).getTime() - new Date(a.date_added).getTime()).reverse();
-
- return (
-
-
-
-
- #
- Title
- Album
- Duration
- Artist
-
-
-
- {sortedSongs.map((song: LibrarySong & { album_object: Album, artist_object: Artist }, index) => (
-
-
- handlePlay(song.album_object.cover_url, song, `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${session && session.bitrate}`, song.artist_object, song.album_object)}>
- {index + 1}
-
-
-
- e.stopPropagation()} href={`/album?id=${song.album_object.id}`}>{song.album_object.name}
- {formatDuration(song.duration)}
- e.stopPropagation()}href={`/artist?id=${song.artist_object.id}`}>{song.artist_object.name}
-
-
-
- ))}
-
-
- )
-}
diff --git a/apps/web/components/Music/Queue/QueuePanel.tsx b/apps/web/components/Music/Queue/QueuePanel.tsx
deleted file mode 100644
index 51809b46..00000000
--- a/apps/web/components/Music/Queue/QueuePanel.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client"
-
-import getBaseURL from "@/lib/Server/getBaseURL";
-
-import { Album, Artist, LibrarySong } from "@music/sdk/types";
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@music/ui/components/resizable";
-import { Separator } from "@music/ui/components/separator";
-import Image from "next/image";
-import Link from "next/link";
-import { useContext, useEffect, useState } from "react";
-import { usePlayer } from "../Player/usePlayer";
-import { PanelContext } from "./QueuePanelContext";
-
-type QueuePanelProps = {
- children: React.ReactNode
-}
-
-function ChildrenComponent({ children }: QueuePanelProps) {
- return (
- {children}
- )
-}
-
-export default function QueuePanel({ children }: QueuePanelProps) {
- const { isPanelVisible } = useContext(PanelContext);
-
- const { queue } = usePlayer()
-
- return (
-
- {children}
- {isPanelVisible && (
- <>
-
-
- {queue && queue.map(queueItem => {
- return (
-
- );
- })}
-
- >
- )}
-
- );
-}
-
-type Queue = {
- queueItem: {
- song: LibrarySong
- artist: Artist,
- album: Album
- }
-}
-
-function QueueItem({ queueItem }: Queue) {
- const [imageSrc, setImageSrc] = useState("")
-
- useEffect(() => {
- setImageSrc(`${getBaseURL()}/image/${encodeURIComponent(queueItem.album.cover_url)}`)
- }, [queueItem.album.cover_url])
-
- const { song, artist, album } = queueItem
-
- return (
- <>
-
-
-
-
{song.name}
-
{artist.name}
-
-
-
- >
- )
-}
diff --git a/apps/web/components/Music/Queue/QueuePanelContext.tsx b/apps/web/components/Music/Queue/QueuePanelContext.tsx
deleted file mode 100644
index 6521ec65..00000000
--- a/apps/web/components/Music/Queue/QueuePanelContext.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client"
-
-import { createContext, useState } from 'react';
-
-export const PanelContext = createContext({
- isPanelVisible: false,
- togglePanel: () => {},
-});
-
-type PanelProviderProps = {
- children: React.ReactNode
-}
-
-export default function PanelProvider({ children }: PanelProviderProps) {
- const [isPanelVisible, setPanelVisible] = useState(false);
-
- const togglePanel = () => {
- setPanelVisible(prev => !prev);
- };
-
- return (
-
- {children}
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/components/Music/SongContextMenu.tsx b/apps/web/components/Music/SongContextMenu.tsx
deleted file mode 100644
index a3fcd56f..00000000
--- a/apps/web/components/Music/SongContextMenu.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuSub,
- ContextMenuSubContent,
- ContextMenuSubTrigger,
- ContextMenuTrigger
-} from "@music/ui/components/context-menu";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger
-} from "@music/ui/components/dialog";
-import {
- Table,
- TableBody,
- TableCell,
- TableHeader,
- TableRow
-} from "@music/ui/components/table";
-
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { addSongToPlaylist, getPlaylists, PlaylistsResponse } from "@music/sdk";
-import { Album, Artist, LibrarySong } from "@music/sdk/types";
-import { CircleArrowUp, CirclePlus, ExternalLink, ListEnd, Plus, UserRoundSearch } from "lucide-react";
-import Link from "next/link";
-import { useEffect, useState, useTransition } from "react";
-import Bars3Left from "../Icons/Bars3Left";
-import { usePlayer } from "./Player/usePlayer";
-
-export default function SongContextMenu({
- children,
- song,
- artist,
- album,
-}: {
- children: React.ReactNode;
- song: LibrarySong;
- artist: Artist;
- album: Album;
-}) {
- const [playlists, setPlaylists] = useState(null);
-
- useEffect(() => {
- const getPlaylistsRequest = async () => {
- const session = getSession()
- let playlists = await getPlaylists(Number(session?.sub) ?? 0)
- setPlaylists(playlists);
- }
- getPlaylistsRequest();
- }, []);
-
- const [isPending, startTransition] = useTransition()
-
- const { addToQueue } = usePlayer()
-
- return (
-
-
- {children}
-
-
-
-
-
- Add to Playlist
-
- {playlists && playlists.length !== 0 && (
-
- {playlists?.map((playlist) => (
-
- addSongToPlaylist(playlist.id, song.id)}>{playlist.name}
-
- ))}
-
- )}
-
-
- addToQueue(song, album, artist)}>
-
- Add to Queue
-
-
-
-
- Add to Liked
-
-
-
-
-
-
-
- Go to Artist
-
-
-
-
-
- Go to Album
-
-
-
-
- View Credits
-
-
-
-
-
-
- Share
-
-
- Copy Song Link
- Embed track
-
-
-
-
-
-
- View Properties
-
-
-
-
-
-
- Song Information
-
-
-
-
- Song Name
- {song.name}
-
-
-
- Artist
- {song.artist}
-
-
-
-
- Contributing Artists
-
-
- {song.contributing_artists.length === 0
- ? "N/A"
- : song.contributing_artists.join(", ")}
-
-
-
-
- Track Number
- {song.track_number}
-
-
-
- ID
- {song.id}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/Providers/GradientHoverProvider.tsx b/apps/web/components/Providers/GradientHoverProvider.tsx
deleted file mode 100644
index 70ebb57c..00000000
--- a/apps/web/components/Providers/GradientHoverProvider.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client"
-
-import { ReactNode, createContext, useState, useContext } from 'react';
-
-interface GradientHoverContextType {
- gradient: string;
- setGradient: React.Dispatch>;
-}
-
-const GradientHoverContext = createContext(undefined);
-
-export const GradientHoverProvider = ({ children }: { children: ReactNode }) => {
- const [gradient, setGradient] = useState('#CBC3E3');
-
- return (
-
- {children}
-
- );
-};
-
-export const useGradientHover = (): GradientHoverContextType => {
- const context = useContext(GradientHoverContext);
- if (!context) {
- throw new Error('useGradientHover must be used within a GradientHoverProvider');
- }
- return context;
-};
\ No newline at end of file
diff --git a/apps/web/components/Providers/Providers.tsx b/apps/web/components/Providers/Providers.tsx
deleted file mode 100644
index eadb4015..00000000
--- a/apps/web/components/Providers/Providers.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { ReactNode } from 'react';
-import AIOverlayProvider from '../AI/AIOverlayContext';
-import LyricsOverlayProvider from '../Lyrics/LyricsOverlayContext';
-import { PlayerProvider } from '../Music/Player/usePlayer';
-import PanelProvider from '../Music/Queue/QueuePanelContext';
-import { GradientHoverProvider } from './GradientHoverProvider';
-import { ScrollProvider } from './ScrollProvider';
-import SidebarProvider from './SideBarProvider';
-import { ThemeProvider } from './ThemeProvider';
-
-interface ProvidersProps {
- children: ReactNode;
-}
-
-export default function Providers({ children }: ProvidersProps) {
- return (
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/components/Providers/ScrollProvider.tsx b/apps/web/components/Providers/ScrollProvider.tsx
deleted file mode 100644
index 1e1a6d04..00000000
--- a/apps/web/components/Providers/ScrollProvider.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client"
-
-import React, { createContext, useState, useEffect, useRef } from 'react';
-
-export const ScrollContext = createContext({
- onTopOfPage: true,
-});
-
-type ScrollProviderProps = {
- children: React.ReactNode
-}
-
-export const ScrollProvider = ({ children }: ScrollProviderProps) => {
- const [onTopOfPage, setOnTopOfPage] = useState(true);
- const scrollContainer = useRef(null);
-
- useEffect(() => {
- const handleScroll = () => {
- if (scrollContainer.current) {
- setOnTopOfPage(scrollContainer.current.scrollTop === 0);
- }
- };
-
- const container = scrollContainer.current;
-
- if (container) {
- container.addEventListener('scroll', handleScroll);
-
- return () => {
- container.removeEventListener('scroll', handleScroll);
- };
- }
- }, []);
-
-
- return (
-
-
- {children}
-
-
- );
-};
\ No newline at end of file
diff --git a/apps/web/components/Providers/SideBarProvider.tsx b/apps/web/components/Providers/SideBarProvider.tsx
deleted file mode 100644
index 715a9be5..00000000
--- a/apps/web/components/Providers/SideBarProvider.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-"use client"
-
-import { createContext, useState, useContext, ReactNode } from 'react';
-
-type SidebarContextType = {
- isOpen: boolean;
- openSidebar: () => void;
- closeSidebar: () => void;
- toggleSidebar: () => void;
-};
-
-const SidebarContext = createContext(undefined);
-
-type SidebarProviderProps = {
- children: ReactNode
-}
-
-export default function SidebarProvider({ children }: SidebarProviderProps) {
- const [isOpen, setIsOpen] = useState(true);
-
- const openSidebar = () => setIsOpen(true);
- const closeSidebar = () => setIsOpen(false);
- const toggleSidebar = () => setIsOpen(prevIsOpen => !prevIsOpen);
-
- return (
-
- {children}
-
- );
-};
-
-export const useSidebar = () => {
- const context = useContext(SidebarContext);
- if (context === undefined) {
- throw new Error('useSidebar must be used within a SidebarProvider');
- }
- return context;
-};
\ No newline at end of file
diff --git a/apps/web/components/Providers/ThemeProvider.tsx b/apps/web/components/Providers/ThemeProvider.tsx
deleted file mode 100644
index 8c90fbc8..00000000
--- a/apps/web/components/Providers/ThemeProvider.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ThemeProvider as NextThemesProvider } from "next-themes"
-import { type ThemeProviderProps } from "next-themes/dist/types"
-
-export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
- return {children}
-}
diff --git a/apps/web/components/Search/SearchBar.tsx b/apps/web/components/Search/SearchBar.tsx
deleted file mode 100644
index 690ff07b..00000000
--- a/apps/web/components/Search/SearchBar.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-"use client";
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { addSearchHistory, deleteItemFromSearchHistory, getLastSearchedQueries } from "@music/sdk";
-import { SearchItem } from "@music/sdk/types";
-import { Input } from "@music/ui/components/input";
-import { HistoryIcon, Search, Trash2Icon } from "lucide-react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { FormEvent, useContext, useEffect, useState } from "react";
-import { LyricsContext } from "../Lyrics/LyricsOverlayContext";
-import { ScrollContext } from "../Providers/ScrollProvider";
-
-type SearchBarProps = {
- isSearchActive: boolean;
- setIsSearchActive: Function;
-};
-
-export default function SearchBar({
- isSearchActive,
- setIsSearchActive,
-}: SearchBarProps) {
- const [query, setQuery] = useState("");
- const [searchHistory, setSearchHistory] = useState([]);
- const [isSearchBoxClicked, setIsSearchBoxClicked] = useState(false);
- const { onTopOfPage } = useContext(ScrollContext);
- const { areLyricsVisible } = useContext(LyricsContext)
-
- const router = useRouter();
-
- const handleSubmit = (event: FormEvent) => {
- const session = getSession()
- event.preventDefault();
- session && addSearchHistory({ user_id: Number(session.sub), search: query })
- router.push(`/search?q=${query}`);
- setIsSearchActive(false);
- };
-
- useEffect(() => {
- const session = getSession()
- async function getLastSearchedQueriesFn() {
- if (session) {
- const queries = await getLastSearchedQueries({ user_id: Number(session.sub) });
- const uniqueQueries = Array.from(
- new Set(queries.map((item) => JSON.stringify(item)))
- ).map((item) => JSON.parse(item));
- setSearchHistory(uniqueQueries);
- }
- }
-
- getLastSearchedQueriesFn();
- }, []);
-
- return (
-
-
-
- setIsSearchBoxClicked(true)}
- onBlur={() => setIsSearchBoxClicked(false)}
- onChange={(e) => setQuery(e.target.value)}
- />
-
-
setIsSearchActive(!isSearchActive)}
- />
- {isSearchBoxClicked && searchHistory.length !== 0 && (
- e.preventDefault()}
- >
- {searchHistory.map((item) => (
-
-
-
-
-
{
- deleteItemFromSearchHistory({ id: item.id })
- setSearchHistory(
- searchHistory.filter(
- (newItem) => newItem.id !== item.id
- )
- );
- }}
- >
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/apps/web/components/Themes/ToggleTheme.tsx b/apps/web/components/Themes/ToggleTheme.tsx
deleted file mode 100644
index 07dedfc3..00000000
--- a/apps/web/components/Themes/ToggleTheme.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Moon, Sun } from "lucide-react"
-import { useTheme } from "next-themes"
-
-import { Button } from "@music/ui/components/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@music/ui/components/dropdown-menu"
-
-export default function ToggleTheme() {
- const { setTheme } = useTheme()
-
- return (
-
-
-
-
-
- Toggle theme
-
-
-
- setTheme("light")}>
- Light
-
- setTheme("dark")}>
- Dark
-
- setTheme("system")}>
- System
-
-
-
- )
-}
diff --git a/apps/web/components/User/ChangeBitrate.tsx b/apps/web/components/User/ChangeBitrate.tsx
deleted file mode 100644
index ecf2e803..00000000
--- a/apps/web/components/User/ChangeBitrate.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-"use client";
-
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@music/ui/components/select";
-
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { setBitrate } from "@music/sdk";
-import { Button } from "@music/ui/components/button";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-const FormSchema = z.object({
- bitrate: z.string({
- required_error: "Please select a bitrate.",
- }),
-});
-
-export default function ChangeBitrate() {
- const bitrateMapping: { [key: string]: number } = {
- low: 96,
- normal: 128,
- high: 256,
- lossless: 0,
- };
-
- const selectedBitrate = Object.keys(bitrateMapping).find(
- (key) => bitrateMapping[key] === 0
- );
-
- const form = useForm>({
- resolver: zodResolver(FormSchema),
- defaultValues: {
- bitrate: selectedBitrate,
- },
- });
-
- async function onSubmit(data: z.infer) {
- const bitrateMapping: {
- [key in z.infer["bitrate"]]: number;
- } = {
- low: 96,
- normal: 128,
- high: 256,
- lossless: 0,
- };
-
- const newBitrate = bitrateMapping[data.bitrate];
-
- if (newBitrate !== undefined) {
- const session = getSession()
- setBitrate(Number(session?.sub), newBitrate);
- }
- }
-
- function onBitrateChange(value: string) {
- form.setValue("bitrate", value);
- form.trigger("bitrate");
- }
-
- return (
-
-
-
- (
-
- Audio Quality
-
-
-
-
-
-
-
-
- Low
- Normal
- High
- Lossless
-
-
-
- Set the audio quality when streaming, Low = 96kbps, Normal =
- 128kbps, High = 256kbps...
-
-
-
- )}
- />
-
- Change Quality
-
-
-
-
- );;
-}
diff --git a/apps/web/components/User/ChangePassword.tsx b/apps/web/components/User/ChangePassword.tsx
deleted file mode 100644
index d144e61f..00000000
--- a/apps/web/components/User/ChangePassword.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import getSession from "@/lib/Authentication/JWT/getSession";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { changePassword } from "@music/sdk";
-import { Button } from "@music/ui/components/button";
-import { FormItem, FormLabel, FormControl, FormMessage } from "@music/ui/components/form";
-import { Input } from "@music/ui/components/input";
-import { Form, useForm, FormProvider } from "react-hook-form";
-import { z } from "zod";
-
-const changePasswordSchema = z.object({
- password: z
- .string()
- .min(4, { message: "Password must be longer than 4 characters" }),
- confirmPassword: z
- .string()
- .min(4, { message: "Password must be longer than 4 characters" }),
-});
-
-export function ChangePassword() {
- const [isClient, setIsClient] = useState(false);
- const session = getSession();
-
- useEffect(() => {
- setIsClient(true);
- }, []);
-
- const form = useForm>({
- resolver: zodResolver(changePasswordSchema),
- defaultValues: {
- password: "",
- confirmPassword: "",
- },
- });
-
- const onSubmit = async (data: z.infer) => {
- if (data.password === data.confirmPassword) {
- await changePassword(session?.username ?? "", data.password, data.confirmPassword);
- } else {
- // handle password mismatch
- }
- };
-
- if (!isClient) {
- return null;
- }
-
- return (
-
-
-
- Change Password
-
-
-
- Password
-
-
-
-
-
-
- Confirm Password
-
-
-
-
-
-
- Update Password
-
-
-
-
-
- );;
-}
\ No newline at end of file
diff --git a/apps/web/components/User/NavbarProfilePicture.tsx b/apps/web/components/User/NavbarProfilePicture.tsx
deleted file mode 100644
index b6101f3f..00000000
--- a/apps/web/components/User/NavbarProfilePicture.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client"
-
-import {
- LogOut,
- Settings,
- User,
-} from "lucide-react"
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuTrigger,
-} from "@music/ui/components/dropdown-menu"
-
-import {
- Dialog,
- DialogTrigger,
-} from "@music/ui/components/dialog"
-
-import { Avatar, AvatarFallback, AvatarImage } from "@music/ui/components/avatar"
-import Link from "next/link"
-import SettingsDialog from "./SettingsDialog"
-
-import getSession from "@/lib/Authentication/JWT/getSession"
-import { getProfilePicture } from "@music/sdk"
-import { deleteCookie } from "cookies-next"
-import { Inter as FontSans } from "next/font/google"
-import { useRouter } from "next/navigation"
-import { useEffect, useState } from "react"
-import { cn } from "@music/ui/lib/utils"
-
-const fontSans = FontSans({
- subsets: ["latin"],
- variable: "--font-sans",
-})
-
-export default function NavbarProfilePicture() {
- const [username, setUsername] = useState("")
- const [profilePicture, setProfilePicture] = useState(null)
-
- useEffect(() => {
- const fetchSessionAndProfilePicture = async () => {
- const session = getSession()
- if (session) {
- setUsername(session.username)
- const profilePic = await getProfilePicture(Number(session.sub))
- if (profilePic) {
- setProfilePicture(URL.createObjectURL(profilePic))
- } else {
- setProfilePicture(null)
- }
- }
- }
- fetchSessionAndProfilePicture()
- }, [])
-
- const { push } = useRouter()
- function signOut() {
- deleteCookie("music_jwt")
- push("/login")
- }
-
- function removeServer() {
- localStorage.removeItem("server")
- push("/")
- }
-
- return (
-
-
-
-
- {profilePicture ? (
-
- ) : (
- {username.substring(0, 2).toUpperCase()}
- )}
-
-
-
- My Account
-
-
-
-
-
- Profile
-
-
-
-
-
-
- Settings
-
-
-
-
-
- signOut()}>
-
- Log out
- ⇧⌘Q
-
-
- removeServer()}>
-
- Change Server
- ⇧⌘Q
-
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/apps/web/components/User/SettingsDialog.tsx b/apps/web/components/User/SettingsDialog.tsx
deleted file mode 100644
index 3536eb5f..00000000
--- a/apps/web/components/User/SettingsDialog.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { invalidateCache } from "@/lib/Caching/cache";
-import { Button } from "@music/ui/components/button";
-import {
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@music/ui/components/dialog";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@music/ui/components/tabs";
-import ChangeBitrate from "./ChangeBitrate";
-import { ChangePassword } from "./ChangePassword";
-import Username from "./Username";
-
-export default function SettingsDialog() {
-
- return (
-
-
- Settings
-
- Make yourself at home. Change your username, password, or even the
- audio quality.
-
-
-
-
-
- General
- Audio
- Cache
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{}}>
- Everything
-
-
-
-
-
invalidateCache("landingCarousel")}>
- Landing Album
-
-
-
-
-
invalidateCache("fromYourLibrary")}>
- From Your Library
-
-
-
-
-
invalidateCache("randomSongs")}>
- Random Songs
-
-
-
-
-
invalidateCache("recommendedAlbums")}>
- Recommended Albums
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/User/Username.tsx b/apps/web/components/User/Username.tsx
deleted file mode 100644
index f97b5a7c..00000000
--- a/apps/web/components/User/Username.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client"
-
-import { Input } from "@music/ui/components/input"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import getSession from "@/lib/Authentication/JWT/getSession"
-import { Button } from "@music/ui/components/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@music/ui/components/form"
-
-export default function Username() {
- const session = getSession()
- const username = session?.username
-
- const FormSchema = z.object({
- username: z.string()
- .min(2, {
- message: "Username must be at least 2 characters.",
- })
- .refine(value => !value.includes(' '), {
- message: "Username cannot contain a space",
- }),
- });
-
- const form = useForm>({
- resolver: zodResolver(FormSchema),
- defaultValues: {
- username: "",
- },
- })
-
- function onSubmit(data: z.infer) {
- console.log(data.username)
- }
-
- return (
-
-
- (
-
- Username
-
-