Load newest albums and complete song pages, add a Songs view, bundle FFmpeg, persist credentials securely, and serve the managed library on the LAN

Hop-State: A_06FNEJ4B52YWW6V4DQKVHXR
Hop-Proposal: R_06FNEJ37KHGP1VCKVNTV95R
Hop-Task: T_06FNEEBRXR47KT7H71N5H68
Hop-Attempt: AT_06FNEEBRXS5YEZ0KZ4MTDZG
This commit is contained in:
2026-07-12 10:03:19 -07:00
committed by Hop
parent 0168767819
commit 73c4eb1857
17 changed files with 1191 additions and 58 deletions
+3
View File
@@ -2,7 +2,10 @@ node_modules/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/binaries/navidrome-* src-tauri/binaries/navidrome-*
src-tauri/binaries/ffmpeg-*
src-tauri/resources/navidrome-LICENSE src-tauri/resources/navidrome-LICENSE
src-tauri/resources/ffmpeg-LICENSE
src-tauri/resources/ffmpeg-README
.DS_Store .DS_Store
*.log *.log
*.tsbuildinfo *.tsbuildinfo
+14 -15
View File
@@ -6,17 +6,17 @@ Resonant is a desktop-first music player for personal collections. The desktop a
## What works in v0.1 ## What works in v0.1
- A responsive library home with albums, tracks, favorites, search, and queue management - A responsive library home with paginated newest albums, a complete Songs view, favorites, search, and queue management
- Functional playback controls with seek, volume, previous, and next actions - Functional playback controls with seek, volume, previous, and next actions
- An offline demo library with locally synthesized audio, so the app is useful immediately after cloning - An offline demo library with locally synthesized audio, so the app is useful immediately after cloning
- OpenSubsonic token authentication, connection testing, album loading, random-track loading, artwork, and streaming URLs - OpenSubsonic token authentication, connection testing, complete album/song pagination, artwork, and streaming URLs
- A managed Navidrome 0.61.2 sidecar bound to `127.0.0.1`, with native folder selection, first-user setup, process lifecycle control, and app-private server data - Managed Navidrome 0.61.2 and FFmpeg sidecars, with native folder selection, first-user setup, transcoding, LAN access, process lifecycle control, and app-private server data
- Official release download and SHA-256 verification for Apple Silicon/Intel macOS, x64/ARM64 Linux, and x64 Windows desktop builds - Official release download and SHA-256 verification for Apple Silicon/Intel macOS, x64/ARM64 Linux, and x64 Windows desktop builds
- A charcoal and onyx interface with coral reserved for active state and primary action - A charcoal and onyx interface with coral reserved for active state and primary action
- A Tauri 2 desktop shell for macOS, Windows, and Linux development - A Tauri 2 desktop shell for macOS, Windows, and Linux development
- Keyboard-visible controls, reduced-motion support, semantic labels, and a WCAG 2.2 AA target - Keyboard-visible controls, reduced-motion support, semantic labels, and a WCAG 2.2 AA target
The server password is held only in memory. Resonant remembers the server address and username locally, but secure credential persistence is intentionally deferred until the desktop keychain integration is added. Local and remote server passwords are stored through the operating system credential store. Resonant restores the selected library automatically on the next desktop launch.
## Run it ## Run it
@@ -33,7 +33,7 @@ To run the desktop shell with the bundled server:
npm run desktop:dev npm run desktop:dev
``` ```
The first desktop run downloads the pinned official Navidrome binary for the current Rust target, verifies its release digest, and places it in Tauri's ignored sidecar build directory. The binary is bundled into packaged apps, not committed to this repository. The first desktop run downloads the pinned official Navidrome binary for the current Rust target, verifies its release digest, and prepares the platform's pinned FFmpeg static binary. Both executables and their license notices are bundled into packaged apps, not committed to this repository.
The generic Tauri entry point also prepares the sidecar automatically, so `npm run tauri dev` and `npm run tauri build` are supported as well. The generic Tauri entry point also prepares the sidecar automatically, so `npm run tauri dev` and `npm run tauri build` are supported as well.
@@ -47,10 +47,10 @@ npm run check
Open the library control in the lower-left corner. Open the library control in the lower-left corner.
- **This computer:** choose a music folder and a local password. Resonant starts its bundled Navidrome process on `127.0.0.1:4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes. The app clears its demo content while Navidrome scans and waits for real indexed tracks before showing the library. - **This computer:** choose a music folder and a library password. Resonant starts its bundled Navidrome process on port `4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes. It shows the LAN address other computers can use, clears demo content while scanning, and waits for real indexed tracks before showing the library.
- **Existing server:** enter the URL and credentials for a Navidrome or OpenSubsonic server you already run. - **Existing server:** enter the URL and credentials for a Navidrome or OpenSubsonic server you already run.
The local password and remote server password are held only in memory. Browser development can preview the interface and connect to servers that allow the development origin, but native folder selection and the managed server require the Tauri desktop app. Passwords are saved in Keychain on macOS, Credential Manager on Windows, or Secret Service on Linux. Browser development can preview the interface and connect to servers that allow the development origin, but secure credential persistence, native folder selection, and the managed server require the Tauri desktop app.
## Architecture ## Architecture
@@ -65,7 +65,8 @@ Resonant desktop client
└── Tauri shell └── Tauri shell
└── managed Navidrome sidecar └── managed Navidrome sidecar
├── official, pinned, checksum-verified binary ├── official, pinned, checksum-verified binary
├── loopback-only HTTP server ├── bundled FFmpeg transcoder
├── private-network HTTP server
└── app-private database and logs └── app-private database and logs
Navidrome or another OpenSubsonic server Navidrome or another OpenSubsonic server
@@ -76,19 +77,17 @@ Navidrome or another OpenSubsonic server
The protocol boundary is deliberate. Resonant owns the product experience without forking the media engine, and the server can be replaced as long as it speaks OpenSubsonic. The protocol boundary is deliberate. Resonant owns the product experience without forking the media engine, and the server can be replaced as long as it speaks OpenSubsonic.
## Current boundaries ## Network boundary
- Navidrome is bundled; FFmpeg is not yet bundled. Native browser-supported audio formats play directly, while formats that require transcoding still need FFmpeg available on the system. The managed server is reachable by devices on the same LAN and is protected by the library password. It intentionally does not configure routers, public DNS, certificates, or internet exposure. Do not forward port `4533` directly to the public internet; remote access should use a trusted VPN or a TLS reverse proxy.
- The local server is intentionally reachable only from this computer. Listening from another device will be a separate, explicitly enabled feature.
- Credentials are session-only until OS keychain integration lands.
## Next milestones ## Next milestones
1. Move remote requests into Tauri's HTTP plugin and store credentials in the OS keychain. 1. Move remote requests into Tauri's HTTP plugin for stricter native networking control.
2. Add album and artist detail routes with deterministic library pagination. 2. Add album and artist detail routes.
3. Persist the queue, favorites, and playback position, then synchronize favorites back to the server. 3. Persist the queue, favorites, and playback position, then synchronize favorites back to the server.
4. Add native media keys, system now-playing metadata, ReplayGain, and gapless transition support. 4. Add native media keys, system now-playing metadata, ReplayGain, and gapless transition support.
5. Add an explicit remote-access flow without weakening the local-only default. 5. Add a guided VPN/TLS remote-access flow without silently exposing the library to the internet.
Product direction and accessibility principles live in [`PRODUCT.md`](./PRODUCT.md). Product direction and accessibility principles live in [`PRODUCT.md`](./PRODUCT.md).
Third-party licensing and source links live in [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). Third-party licensing and source links live in [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md).
+11
View File
@@ -10,3 +10,14 @@ Resonant desktop builds bundle an unmodified Navidrome 0.61.2 executable as the
- License text: https://github.com/navidrome/navidrome/blob/v0.61.2/LICENSE - License text: https://github.com/navidrome/navidrome/blob/v0.61.2/LICENSE
Navidrome is downloaded from its official GitHub release during the desktop build and verified against the SHA-256 digest published with that release. Navidrome is a separate process and is not modified by Resonant. Navidrome is downloaded from its official GitHub release during the desktop build and verified against the SHA-256 digest published with that release. Navidrome is a separate process and is not modified by Resonant.
## FFmpeg
Resonant desktop builds bundle a platform-specific FFmpeg executable for media transcoding. The binary is supplied by `ffmpeg-static` 5.3.0 and reports FFmpeg 6.0 on Apple Silicon.
- Project: https://ffmpeg.org/
- Source: https://github.com/FFmpeg/FFmpeg/tree/n6.0
- Binary packaging: https://github.com/eugeneware/ffmpeg-static/tree/5.3.0
- License: GNU General Public License v3.0 or later
The exact upstream binary build information and license copied by `ffmpeg-static` are included in every packaged Resonant app.
+226
View File
@@ -25,6 +25,7 @@
"@types/spark-md5": "^3.0.5", "@types/spark-md5": "^3.0.5",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"esbuild": "latest", "esbuild": "latest",
"ffmpeg-static": "5.3.0",
"jsdom": "latest", "jsdom": "latest",
"typescript": "latest", "typescript": "latest",
"vite": "^8.1.4", "vite": "^8.1.4",
@@ -279,6 +280,22 @@
"node": ">=20.19.0" "node": ">=20.19.0"
} }
}, },
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -1952,6 +1969,19 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -2007,6 +2037,20 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2017,6 +2061,22 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"dev": true,
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2066,6 +2126,24 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2114,6 +2192,16 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
@@ -2201,6 +2289,23 @@
} }
} }
}, },
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"dev": true,
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2229,6 +2334,37 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/http-response-object/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/indent-string": { "node_modules/indent-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -2239,6 +2375,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/is-potential-custom-element-name": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -2613,6 +2756,13 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -2646,6 +2796,12 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
"dev": true
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -2731,6 +2887,16 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
} }
}, },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2770,6 +2936,21 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -2828,6 +3009,27 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5" "@rolldown/binding-win32-x64-msvc": "1.1.5"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/saxes": { "node_modules/saxes": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -2884,6 +3086,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -3002,6 +3214,13 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -3054,6 +3273,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.4", "version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+1
View File
@@ -32,6 +32,7 @@
"@types/spark-md5": "^3.0.5", "@types/spark-md5": "^3.0.5",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"esbuild": "latest", "esbuild": "latest",
"ffmpeg-static": "5.3.0",
"jsdom": "latest", "jsdom": "latest",
"typescript": "latest", "typescript": "latest",
"vite": "^8.1.4", "vite": "^8.1.4",
+21 -2
View File
@@ -4,6 +4,7 @@ import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:f
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import ffmpegPath from "ffmpeg-static";
const VERSION = "0.61.2"; const VERSION = "0.61.2";
const RELEASE_API = `https://api.github.com/repos/navidrome/navidrome/releases/tags/v${VERSION}`; const RELEASE_API = `https://api.github.com/repos/navidrome/navidrome/releases/tags/v${VERSION}`;
@@ -26,13 +27,17 @@ if (!archiveName) {
const extension = targetTriple.includes("windows") ? ".exe" : ""; const extension = targetTriple.includes("windows") ? ".exe" : "";
const outputDirectory = join(projectRoot, "src-tauri/binaries"); const outputDirectory = join(projectRoot, "src-tauri/binaries");
const outputPath = join(outputDirectory, `navidrome-${targetTriple}${extension}`); const outputPath = join(outputDirectory, `navidrome-${targetTriple}${extension}`);
const ffmpegOutputPath = join(outputDirectory, `ffmpeg-${targetTriple}${extension}`);
const resourceDirectory = join(projectRoot, "src-tauri/resources"); const resourceDirectory = join(projectRoot, "src-tauri/resources");
const licensePath = join(resourceDirectory, "navidrome-LICENSE"); const licensePath = join(resourceDirectory, "navidrome-LICENSE");
const ffmpegLicensePath = join(resourceDirectory, "ffmpeg-LICENSE");
const ffmpegReadmePath = join(resourceDirectory, "ffmpeg-README");
await mkdir(outputDirectory, { recursive: true }); await mkdir(outputDirectory, { recursive: true });
await mkdir(resourceDirectory, { recursive: true }); await mkdir(resourceDirectory, { recursive: true });
let binaryReady = false; let binaryReady = false;
let licenseReady = false; let licenseReady = false;
let ffmpegReady = false;
try { try {
await readFile(outputPath); await readFile(outputPath);
binaryReady = true; binaryReady = true;
@@ -45,6 +50,20 @@ try {
} catch { } catch {
// Include Navidrome's GPL license in the packaged app when missing. // Include Navidrome's GPL license in the packaged app when missing.
} }
try {
await readFile(ffmpegOutputPath);
ffmpegReady = true;
} catch {
// Copy the pinned npm-provided binary below when missing.
}
if (!ffmpegPath) throw new Error(`ffmpeg-static does not provide a binary for ${process.platform}-${process.arch}.`);
if (!ffmpegReady) {
await copyFile(ffmpegPath, ffmpegOutputPath);
if (!extension) await chmod(ffmpegOutputPath, 0o755);
}
await copyFile(join(dirname(ffmpegPath), "ffmpeg.LICENSE"), ffmpegLicensePath);
await copyFile(join(dirname(ffmpegPath), "ffmpeg.README"), ffmpegReadmePath);
const headers = { Accept: "application/vnd.github+json", "User-Agent": "Resonant-sidecar-preparer" }; const headers = { Accept: "application/vnd.github+json", "User-Agent": "Resonant-sidecar-preparer" };
if (!licenseReady) { if (!licenseReady) {
@@ -53,7 +72,7 @@ if (!licenseReady) {
await writeFile(licensePath, await licenseResponse.text()); await writeFile(licensePath, await licenseResponse.text());
} }
if (binaryReady) { if (binaryReady) {
process.stdout.write(`Navidrome ${VERSION} sidecar is ready for ${targetTriple}.\n`); process.stdout.write(`Navidrome ${VERSION} and FFmpeg sidecars are ready for ${targetTriple}.\n`);
process.exit(0); process.exit(0);
} }
@@ -84,4 +103,4 @@ try {
await rm(workingDirectory, { recursive: true, force: true }); await rm(workingDirectory, { recursive: true, force: true });
} }
process.stdout.write(`Downloaded and verified Navidrome ${VERSION} for ${targetTriple}.\n`); process.stdout.write(`Downloaded and verified Navidrome ${VERSION}; FFmpeg is ready for ${targetTriple}.\n`);
+659
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -47,6 +58,148 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -133,6 +286,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.6.2" version = "0.6.2"
@@ -142,6 +304,19 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.4" version = "8.0.4"
@@ -266,6 +441,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
] ]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.67" version = "1.2.67"
@@ -321,6 +505,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -331,6 +525,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -546,6 +749,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -710,6 +914,33 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -737,6 +968,27 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -858,6 +1110,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.32" version = "0.3.32"
@@ -1212,12 +1477,36 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.38.0" version = "0.38.0"
@@ -1485,6 +1774,16 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -1627,6 +1926,27 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "keyring"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
dependencies = [
"log",
]
[[package]] [[package]]
name = "libappindicator" name = "libappindicator"
version = "0.9.0" version = "0.9.0"
@@ -1685,6 +2005,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1810,12 +2136,75 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[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",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -2066,6 +2455,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "1.2.3" version = "1.2.3"
@@ -2101,6 +2500,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2189,6 +2594,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@@ -2234,6 +2650,20 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -2460,6 +2890,7 @@ dependencies = [
name = "resonant" name = "resonant"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"keyring",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -2507,6 +2938,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -2579,6 +3023,48 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -2943,6 +3429,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3357,6 +3849,19 @@ dependencies = [
"toml 1.1.2+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.1" version = "0.5.1"
@@ -3660,9 +4165,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -3712,6 +4229,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"
@@ -4183,6 +4711,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
version = "0.2.0" version = "0.2.0"
@@ -4603,6 +5144,78 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zbus"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
@@ -4624,6 +5237,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
@@ -4662,3 +5281,43 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
]
+1
View File
@@ -13,6 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
keyring = "4.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
+92 -2
View File
@@ -1,5 +1,6 @@
use keyring::{Entry, Error as KeyringError};
use serde::Serialize; use serde::Serialize;
use std::{fs, path::PathBuf, sync::Mutex}; use std::{fs, net::UdpSocket, path::PathBuf, sync::Mutex};
use tauri::{AppHandle, Manager, State, WindowEvent}; use tauri::{AppHandle, Manager, State, WindowEvent};
use tauri_plugin_shell::{ use tauri_plugin_shell::{
process::{CommandChild, CommandEvent}, process::{CommandChild, CommandEvent},
@@ -8,6 +9,9 @@ use tauri_plugin_shell::{
const NAVIDROME_VERSION: &str = "0.61.2"; const NAVIDROME_VERSION: &str = "0.61.2";
const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533"; const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533";
const CREDENTIAL_SERVICE: &str = "xyz.gnosyslabs.resonant";
const LOCAL_CREDENTIAL: &str = "local-admin";
const REMOTE_CREDENTIAL: &str = "remote-server";
struct ManagedServer { struct ManagedServer {
child: CommandChild, child: CommandChild,
@@ -23,19 +27,95 @@ struct ManagedServerState(Mutex<Option<ManagedServer>>);
struct ManagedServerStatus { struct ManagedServerStatus {
running: bool, running: bool,
url: String, url: String,
lan_urls: Vec<String>,
music_folder: Option<String>, music_folder: Option<String>,
version: String, version: String,
} }
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SavedCredentials {
#[serde(skip_serializing_if = "Option::is_none")]
local_password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
remote_password: Option<String>,
}
fn credential_entry(account: &str) -> Result<Entry, String> {
Entry::new(CREDENTIAL_SERVICE, account)
.map_err(|error| format!("The system credential store is unavailable: {error}"))
}
fn read_password(account: &str) -> Result<Option<String>, String> {
match credential_entry(account)?.get_password() {
Ok(password) => Ok(Some(password)),
Err(KeyringError::NoEntry) => Ok(None),
Err(error) => Err(format!("The saved credential could not be read: {error}")),
}
}
fn write_password(account: &str, password: &str) -> Result<(), String> {
credential_entry(account)?
.set_password(password)
.map_err(|error| format!("The credential could not be saved securely: {error}"))
}
fn discover_lan_urls() -> Vec<String> {
let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else {
return Vec::new();
};
if socket.connect("1.1.1.1:80").is_err() {
return Vec::new();
}
let Ok(address) = socket.local_addr() else {
return Vec::new();
};
if address.ip().is_loopback() || !address.ip().is_ipv4() {
return Vec::new();
}
vec![format!("http://{}:4533", address.ip())]
}
fn bundled_sidecar_path(name: &str) -> Result<PathBuf, String> {
let filename = if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
};
let executable = std::env::current_exe()
.map_err(|error| format!("The Resonant executable path is unavailable: {error}"))?;
Ok(executable
.parent()
.ok_or_else(|| "The Resonant executable folder is unavailable.".to_string())?
.join(filename))
}
fn status_for(server: Option<&ManagedServer>) -> ManagedServerStatus { fn status_for(server: Option<&ManagedServer>) -> ManagedServerStatus {
ManagedServerStatus { ManagedServerStatus {
running: server.is_some(), running: server.is_some(),
url: MANAGED_SERVER_URL.to_string(), url: MANAGED_SERVER_URL.to_string(),
lan_urls: discover_lan_urls(),
music_folder: server.map(|server| server.music_folder.clone()), music_folder: server.map(|server| server.music_folder.clone()),
version: NAVIDROME_VERSION.to_string(), version: NAVIDROME_VERSION.to_string(),
} }
} }
#[tauri::command]
fn load_saved_credentials() -> Result<SavedCredentials, String> {
Ok(SavedCredentials {
local_password: read_password(LOCAL_CREDENTIAL)?,
remote_password: read_password(REMOTE_CREDENTIAL)?,
})
}
#[tauri::command]
fn save_remote_password(password: String) -> Result<(), String> {
if password.is_empty() {
return Err("Enter the server password before saving it.".to_string());
}
write_password(REMOTE_CREDENTIAL, &password)
}
fn stop_server(state: &ManagedServerState) -> Result<(), String> { fn stop_server(state: &ManagedServerState) -> Result<(), String> {
let mut server = state let mut server = state
.0 .0
@@ -78,6 +158,7 @@ fn start_managed_server(
if !music_path.is_dir() { if !music_path.is_dir() {
return Err("Choose a folder that contains your music.".to_string()); return Err("Choose a folder that contains your music.".to_string());
} }
write_password(LOCAL_CREDENTIAL, &password)?;
stop_server(&state)?; stop_server(&state)?;
@@ -89,17 +170,23 @@ fn start_managed_server(
fs::create_dir_all(&data_folder) fs::create_dir_all(&data_folder)
.map_err(|error| format!("The local server data folder could not be created: {error}"))?; .map_err(|error| format!("The local server data folder could not be created: {error}"))?;
let log_file = data_folder.join("navidrome.log"); let log_file = data_folder.join("navidrome.log");
let ffmpeg_path = bundled_sidecar_path("ffmpeg")?;
if !ffmpeg_path.is_file() {
return Err("The bundled FFmpeg transcoder is unavailable.".to_string());
}
let sidecar = app let sidecar = app
.shell() .shell()
.sidecar("navidrome") .sidecar("navidrome")
.map_err(|error| format!("The bundled music server is unavailable: {error}"))? .map_err(|error| format!("The bundled music server is unavailable: {error}"))?
.env("ND_ADDRESS", "127.0.0.1") .env("ND_ADDRESS", "0.0.0.0")
.env("ND_PORT", "4533") .env("ND_PORT", "4533")
.env("ND_MUSICFOLDER", &music_path) .env("ND_MUSICFOLDER", &music_path)
.env("ND_DATAFOLDER", &data_folder) .env("ND_DATAFOLDER", &data_folder)
.env("ND_LOGFILE", &log_file) .env("ND_LOGFILE", &log_file)
.env("ND_LOGLEVEL", "info") .env("ND_LOGLEVEL", "info")
.env("ND_FFMPEGPATH", &ffmpeg_path)
.env("ND_TRANSCODING_MAXCONCURRENT", "2")
.env("ND_ENABLEINSIGHTSCOLLECTOR", "false") .env("ND_ENABLEINSIGHTSCOLLECTOR", "false")
.env("ND_ENABLEEXTERNALSERVICES", "false") .env("ND_ENABLEEXTERNALSERVICES", "false")
.env("ND_DEVAUTOCREATEADMINPASSWORD", password); .env("ND_DEVAUTOCREATEADMINPASSWORD", password);
@@ -140,6 +227,7 @@ fn start_managed_server(
Ok(ManagedServerStatus { Ok(ManagedServerStatus {
running: true, running: true,
url: MANAGED_SERVER_URL.to_string(), url: MANAGED_SERVER_URL.to_string(),
lan_urls: discover_lan_urls(),
music_folder: Some(music_folder), music_folder: Some(music_folder),
version: NAVIDROME_VERSION.to_string(), version: NAVIDROME_VERSION.to_string(),
}) })
@@ -161,6 +249,8 @@ pub fn run() {
.manage(ManagedServerState::default()) .manage(ManagedServerState::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
managed_server_status, managed_server_status,
load_saved_credentials,
save_remote_password,
start_managed_server, start_managed_server,
stop_managed_server stop_managed_server
]) ])
+6 -2
View File
@@ -28,7 +28,11 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"externalBin": ["binaries/navidrome"], "externalBin": ["binaries/navidrome", "binaries/ffmpeg"],
"resources": ["resources/navidrome-LICENSE"] "resources": [
"resources/navidrome-LICENSE",
"resources/ffmpeg-LICENSE",
"resources/ffmpeg-README"
]
} }
} }
+79 -30
View File
@@ -30,11 +30,13 @@ import {
} from "lucide-react"; } from "lucide-react";
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
import { usePlayer } from "./hooks/usePlayer"; import { usePlayer } from "./hooks/usePlayer";
import { pollForLibrary } from "./lib/librarySync"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
import { import {
chooseMusicFolder, chooseMusicFolder,
getManagedServerStatus, getManagedServerStatus,
getSavedCredentials,
isDesktopRuntime, isDesktopRuntime,
saveRemotePassword,
startManagedServer, startManagedServer,
stopManagedServer, stopManagedServer,
type ManagedServerStatus, type ManagedServerStatus,
@@ -59,6 +61,7 @@ function IconButton({ label, children, onClick, active = false, className = "" }
const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
{ view: "home", label: "Listen now", icon: <Home size={18} /> }, { view: "home", label: "Listen now", icon: <Home size={18} /> },
{ view: "albums", label: "Albums", icon: <AlbumIcon size={18} /> }, { view: "albums", label: "Albums", icon: <AlbumIcon size={18} /> },
{ view: "songs", label: "Songs", icon: <ListMusic size={18} /> },
{ view: "artists", label: "Artists", icon: <CircleUserRound size={18} /> }, { view: "artists", label: "Artists", icon: <CircleUserRound size={18} /> },
{ view: "favorites", label: "Favorites", icon: <Heart size={18} /> }, { view: "favorites", label: "Favorites", icon: <Heart size={18} /> },
]; ];
@@ -93,15 +96,6 @@ export default function App() {
const player = usePlayer(libraryTracks); const player = usePlayer(libraryTracks);
const hasLibraryTracks = libraryTracks.length > 0; const hasLibraryTracks = libraryTracks.length > 0;
useEffect(() => {
getManagedServerStatus().then((status) => {
setManagedStatus(status);
if (status.musicFolder) {
setLocalSetup((current) => ({ ...current, musicFolder: status.musicFolder ?? current.musicFolder }));
}
}).catch(() => undefined);
}, []);
const visibleTracks = useMemo(() => { const visibleTracks = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase();
let result = view === "favorites" ? libraryTracks.filter((track) => favorites.has(track.id)) : libraryTracks; let result = view === "favorites" ? libraryTracks.filter((track) => favorites.has(track.id)) : libraryTracks;
@@ -122,11 +116,23 @@ export default function App() {
const fetchServerLibrary = async (config: ServerConfig): Promise<LoadedLibrary> => { const fetchServerLibrary = async (config: ServerConfig): Promise<LoadedLibrary> => {
await requestSubsonic(config, "ping"); await requestSubsonic(config, "ping");
const [albumResponse, songResponse] = await Promise.all([ const [serverAlbums, serverSongs] = await Promise.all([
requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(config, "getAlbumList2", { type: "recent", size: 18 }), fetchAllPages(async (offset, size) => {
requestSubsonic<{ randomSongs?: { song?: ServerSong[] } }>(config, "getRandomSongs", { size: 40 }), const response = await requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(config, "getAlbumList2", { type: "newest", size, offset });
return response.albumList2?.album ?? [];
}),
fetchAllPages(async (offset, size) => {
const response = await requestSubsonic<{ searchResult3?: { song?: ServerSong[] } }>(config, "search3", {
query: "",
artistCount: 0,
albumCount: 0,
songCount: size,
songOffset: offset,
});
return response.searchResult3?.song ?? [];
}),
]); ]);
const albums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({ const albums = serverAlbums.map((album, index): Album => ({
id: album.id, id: album.id,
title: album.name, title: album.name,
artist: album.artist ?? "Unknown artist", artist: album.artist ?? "Unknown artist",
@@ -134,7 +140,7 @@ export default function App() {
coverTone: tones[index % tones.length], coverTone: tones[index % tones.length],
coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined, coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
})); }));
const tracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({ const tracks = serverSongs.map((song, index): Track => ({
id: song.id, id: song.id,
title: song.title, title: song.title,
artist: song.artist ?? "Unknown artist", artist: song.artist ?? "Unknown artist",
@@ -172,6 +178,7 @@ export default function App() {
setConnectionMessage("Reaching your library…"); setConnectionMessage("Reaching your library…");
try { try {
await loadServerLibrary(server, `Connected as ${server.username}`); await loadServerLibrary(server, `Connected as ${server.username}`);
await saveRemotePassword(server.password);
localStorage.setItem("resonant.serverUrl", server.url); localStorage.setItem("resonant.serverUrl", server.url);
localStorage.setItem("resonant.username", server.username); localStorage.setItem("resonant.username", server.username);
localStorage.setItem("resonant.sourceMode", "remote"); localStorage.setItem("resonant.sourceMode", "remote");
@@ -191,14 +198,13 @@ export default function App() {
if (musicFolder) setLocalSetup((current) => ({ ...current, musicFolder })); if (musicFolder) setLocalSetup((current) => ({ ...current, musicFolder }));
}; };
const startLocalLibrary = async (event: FormEvent) => { const runLocalLibrary = async (musicFolder: string, password: string) => {
event.preventDefault();
setConnectionStatus("connecting"); setConnectionStatus("connecting");
setConnectionMessage("Starting your private music server…"); setConnectionMessage("Starting your music server…");
try { try {
const status = await startManagedServer(localSetup.musicFolder, localSetup.password); const status = await startManagedServer(musicFolder, password);
setManagedStatus(status); setManagedStatus(status);
const config = { url: status.url, username: "admin", password: localSetup.password }; const config = { url: status.url, username: "admin", password };
let lastError: unknown; let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
@@ -221,10 +227,12 @@ export default function App() {
setLibraryPhase("scanning"); setLibraryPhase("scanning");
setConnectionMessage("Scanning your music folder for real tracks…"); setConnectionMessage("Scanning your music folder for real tracks…");
setView("home"); setView("home");
localStorage.setItem("resonant.musicFolder", localSetup.musicFolder); localStorage.setItem("resonant.musicFolder", musicFolder);
localStorage.setItem("resonant.sourceMode", "local"); localStorage.setItem("resonant.sourceMode", "local");
const library = await pollForLibrary(() => fetchServerLibrary(config)); const library = await pollForLibrary(() => fetchServerLibrary(config), {
isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0,
});
if (!library) { if (!library) {
setLibraryPhase("empty"); setLibraryPhase("empty");
setConnectionStatus("error"); setConnectionStatus("error");
@@ -244,6 +252,11 @@ export default function App() {
} }
}; };
const startLocalLibrary = async (event: FormEvent) => {
event.preventDefault();
await runLocalLibrary(localSetup.musicFolder, localSetup.password);
};
const stopLocalLibrary = async () => { const stopLocalLibrary = async () => {
try { try {
const status = await stopManagedServer(); const status = await stopManagedServer();
@@ -260,6 +273,41 @@ export default function App() {
} }
}; };
useEffect(() => {
let active = true;
const restoreLibrary = async () => {
try {
const [status, credentials] = await Promise.all([
getManagedServerStatus(),
getSavedCredentials(),
]);
if (!active) return;
setManagedStatus(status);
const musicFolder = status.musicFolder ?? localStorage.getItem("resonant.musicFolder") ?? "";
const localPassword = credentials.localPassword ?? "";
const remotePassword = credentials.remotePassword ?? "";
setLocalSetup({ musicFolder, password: localPassword });
setServer((current) => ({ ...current, password: remotePassword }));
const savedMode = localStorage.getItem("resonant.sourceMode");
if (savedMode === "local" && musicFolder && localPassword) {
await runLocalLibrary(musicFolder, localPassword);
} else if (savedMode === "remote" && server.url && server.username && remotePassword) {
await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`);
}
} catch (error) {
if (!active) return;
setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored.");
}
};
void restoreLibrary();
return () => {
active = false;
};
}, []);
const goTo = (next: View) => { const goTo = (next: View) => {
setView(next); setView(next);
setMobileNav(false); setMobileNav(false);
@@ -308,10 +356,11 @@ export default function App() {
{sourceMode === "local" ? ( {sourceMode === "local" ? (
<form className="connection-form" onSubmit={startLocalLibrary}> <form className="connection-form" onSubmit={startLocalLibrary}>
<div className="managed-heading"><div className="managed-icon"><AudioLines size={20} /></div><div><strong>Your music, served privately</strong><p>Resonant includes Navidrome and listens only on this computer.</p></div><span>v{managedStatus?.version ?? "0.61.2"}</span></div> <div className="managed-heading"><div className="managed-icon"><AudioLines size={20} /></div><div><strong>Your music, ready on every device</strong><p>Resonant includes Navidrome and FFmpeg, with private-network access built in.</p></div><span>v{managedStatus?.version ?? "0.61.2"}</span></div>
<label>Music folder<div className="folder-field"><input required readOnly placeholder="Choose the folder that holds your music" value={localSetup.musicFolder} /><button type="button" className="button-secondary" onClick={pickMusicFolder}><FolderOpen size={16} /> Choose folder</button></div></label> <label>Music folder<div className="folder-field"><input required readOnly placeholder="Choose the folder that holds your music" value={localSetup.musicFolder} /><button type="button" className="button-secondary" onClick={pickMusicFolder}><FolderOpen size={16} /> Choose folder</button></div></label>
<label>Local password<input type="password" required minLength={8} autoComplete="new-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Used only to protect the private library on this computer. It stays in memory for this session.</small></label> <label>Library password<input type="password" required minLength={8} autoComplete="current-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Saved in your operating system credential store and used by your other music apps.</small></label>
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div> <div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
{managedStatus?.running && managedStatus.lanUrls.length > 0 && <div className="device-addresses"><strong>Listen from another computer</strong><p>Use this address in Resonant or any OpenSubsonic app on the same private network. Sign in as <b>admin</b> with your library password.</p>{managedStatus.lanUrls.map((url) => <code key={url}>{url}</code>)}</div>}
<div className="form-actions"> <div className="form-actions">
{managedStatus?.running && <button type="button" className="button-secondary button-danger" onClick={stopLocalLibrary}><Power size={16} /> Stop server</button>} {managedStatus?.running && <button type="button" className="button-secondary button-danger" onClick={stopLocalLibrary}><Power size={16} /> Stop server</button>}
<button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button> <button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button>
@@ -326,7 +375,7 @@ export default function App() {
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div> <div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
</form> </form>
)} )}
<div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Private by default" : "Your server stays yours"}</strong><p>{sourceMode === "local" ? "The managed server binds to 127.0.0.1, keeps its database in Resonants app data, and stops when Resonant closes." : "Resonant speaks OpenSubsonic directly. Your password stays in memory for this session."}</p></div></div> <div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Available on your private network" : "Your server stays yours"}</strong><p>{sourceMode === "local" ? "The managed server accepts devices on your LAN, keeps its database in Resonants app data, and stops when Resonant closes. Avoid exposing port 4533 directly to the internet." : "Resonant speaks OpenSubsonic directly and saves the password in your operating system credential store."}</p></div></div>
</section> </section>
) : ( ) : (
<div className="library-view"> <div className="library-view">
@@ -346,14 +395,14 @@ export default function App() {
</section> </section>
)} )}
<section className="content-section" aria-labelledby="albums-heading"> {view !== "songs" && <section className="content-section" aria-labelledby="albums-heading">
<div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}</span><h2 id="albums-heading">{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div> <div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}</span><h2 id="albums-heading">{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div>
{visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? 18 : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => firstTrack && player.play(firstTrack)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>} {visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => firstTrack && player.play(firstTrack)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>}
</section> </section>}
<section className="content-section track-section" aria-labelledby="tracks-heading"> <section className="content-section track-section" aria-labelledby="tracks-heading">
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div> <div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div>
<div className="track-list" role="list">{visibleTracks.slice(0, view === "home" && !query ? 6 : 30).map((track, index) => <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.play(track)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} onClick={() => toggleFavorite(track.id)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={`Add ${track.title} to queue`} onClick={() => player.addToQueue(track)}><Plus size={17} /></IconButton></div>)}</div> <div className="track-list" role="list">{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).map((track, index) => <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.play(track)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} onClick={() => toggleFavorite(track.id)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={`Add ${track.title} to queue`} onClick={() => player.addToQueue(track)}><Plus size={17} /></IconButton></div>)}</div>
</section> </section>
</>} </>}
</div> </div>
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { pollForLibrary } from "./librarySync"; import { fetchAllPages, pollForLibrary } from "./librarySync";
describe("pollForLibrary", () => { describe("pollForLibrary", () => {
it("waits until Navidrome returns indexed tracks", async () => { it("waits until Navidrome returns indexed tracks", async () => {
@@ -24,4 +24,30 @@ describe("pollForLibrary", () => {
await expect(pollForLibrary(load, { attempts: 3, wait })).resolves.toBeNull(); await expect(pollForLibrary(load, { attempts: 3, wait })).resolves.toBeNull();
expect(load).toHaveBeenCalledTimes(3); expect(load).toHaveBeenCalledTimes(3);
}); });
it("can wait for albums and tracks to finish indexing together", async () => {
const load = vi
.fn()
.mockResolvedValueOnce({ albums: [], tracks: [{ id: "early" }] })
.mockResolvedValueOnce({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] });
await expect(pollForLibrary<{ albums: { id: string }[]; tracks: { id: string }[] }>(load, {
attempts: 2,
wait: async () => undefined,
isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0,
})).resolves.toEqual({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] });
});
});
describe("fetchAllPages", () => {
it("loads successive pages until the server returns a partial page", async () => {
const loadPage = vi
.fn()
.mockResolvedValueOnce(["one", "two"])
.mockResolvedValueOnce(["three"]);
await expect(fetchAllPages(loadPage, 2)).resolves.toEqual(["one", "two", "three"]);
expect(loadPage).toHaveBeenNthCalledWith(1, 0, 2);
expect(loadPage).toHaveBeenNthCalledWith(2, 2, 2);
});
}); });
+23 -3
View File
@@ -2,22 +2,42 @@ export interface LibrarySnapshot<TTrack> {
tracks: readonly TTrack[]; tracks: readonly TTrack[];
} }
interface PollOptions { export async function fetchAllPages<T>(
loadPage: (offset: number, size: number) => Promise<readonly T[]>,
pageSize = 500,
maxPages = 100,
): Promise<T[]> {
const items: T[] = [];
for (let page = 0; page < maxPages; page += 1) {
const batch = await loadPage(page * pageSize, pageSize);
items.push(...batch);
if (batch.length < pageSize) break;
}
return items;
}
interface PollOptions<TSnapshot> {
attempts?: number; attempts?: number;
delayMs?: number; delayMs?: number;
wait?: (delayMs: number) => Promise<void>; wait?: (delayMs: number) => Promise<void>;
isReady?: (snapshot: TSnapshot) => boolean;
} }
const waitFor = (delayMs: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delayMs)); const waitFor = (delayMs: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
export async function pollForLibrary<T extends LibrarySnapshot<unknown>>( export async function pollForLibrary<T extends LibrarySnapshot<unknown>>(
load: () => Promise<T>, load: () => Promise<T>,
{ attempts = 120, delayMs = 1_000, wait = waitFor }: PollOptions = {}, {
attempts = 120,
delayMs = 1_000,
wait = waitFor,
isReady = (snapshot) => snapshot.tracks.length > 0,
}: PollOptions<T> = {},
): Promise<T | null> { ): Promise<T | null> {
for (let attempt = 0; attempt < attempts; attempt += 1) { for (let attempt = 0; attempt < attempts; attempt += 1) {
try { try {
const snapshot = await load(); const snapshot = await load();
if (snapshot.tracks.length > 0) return snapshot; if (isReady(snapshot)) return snapshot;
} catch (error) { } catch (error) {
if (attempt === attempts - 1) throw error; if (attempt === attempts - 1) throw error;
} }
+6 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getManagedServerStatus, isDesktopRuntime } from "./managedServer"; import { getManagedServerStatus, getSavedCredentials, isDesktopRuntime } from "./managedServer";
describe("managed server bridge", () => { describe("managed server bridge", () => {
it("reports the browser preview as a non-desktop runtime", () => { it("reports the browser preview as a non-desktop runtime", () => {
@@ -10,7 +10,12 @@ describe("managed server bridge", () => {
await expect(getManagedServerStatus()).resolves.toMatchObject({ await expect(getManagedServerStatus()).resolves.toMatchObject({
running: false, running: false,
url: "http://127.0.0.1:4533", url: "http://127.0.0.1:4533",
lanUrls: [],
version: "0.61.2", version: "0.61.2",
}); });
}); });
it("does not expose credentials in the browser preview", async () => {
await expect(getSavedCredentials()).resolves.toEqual({});
});
}); });
+17 -1
View File
@@ -4,10 +4,16 @@ import { open } from "@tauri-apps/plugin-dialog";
export interface ManagedServerStatus { export interface ManagedServerStatus {
running: boolean; running: boolean;
url: string; url: string;
lanUrls: string[];
musicFolder?: string; musicFolder?: string;
version: string; version: string;
} }
export interface SavedCredentials {
localPassword?: string;
remotePassword?: string;
}
export function isDesktopRuntime(): boolean { export function isDesktopRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
} }
@@ -24,11 +30,21 @@ export async function chooseMusicFolder(): Promise<string | null> {
export async function getManagedServerStatus(): Promise<ManagedServerStatus> { export async function getManagedServerStatus(): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) { if (!isDesktopRuntime()) {
return { running: false, url: "http://127.0.0.1:4533", version: "0.61.2" }; return { running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" };
} }
return invoke<ManagedServerStatus>("managed_server_status"); return invoke<ManagedServerStatus>("managed_server_status");
} }
export async function getSavedCredentials(): Promise<SavedCredentials> {
if (!isDesktopRuntime()) return {};
return invoke<SavedCredentials>("load_saved_credentials");
}
export async function saveRemotePassword(password: string): Promise<void> {
if (!isDesktopRuntime()) return;
await invoke("save_remote_password", { password });
}
export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> { export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app."); if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password }); return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password });
+4
View File
@@ -195,6 +195,10 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.012 275); font-size: 0.73rem; } .connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.012 275); font-size: 0.73rem; }
.connection-feedback--error { color: oklch(0.81 0.09 25); background: oklch(0.28 0.055 24); } .connection-feedback--error { color: oklch(0.81 0.09 25); background: oklch(0.28 0.055 24); }
.connection-feedback--connected { color: oklch(0.81 0.08 145); background: oklch(0.26 0.05 145); } .connection-feedback--connected { color: oklch(0.81 0.08 145); background: oklch(0.26 0.05 145); }
.device-addresses { display: grid; gap: 6px; padding: 14px; border: 1px solid oklch(0.39 0.035 145); border-radius: 10px; background: oklch(0.21 0.025 145); }
.device-addresses strong { font-size: 0.75rem; }
.device-addresses p { margin: 0 0 3px; color: var(--muted); font-size: 0.68rem; line-height: 1.5; }
.device-addresses code { width: fit-content; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: oklch(0.84 0.08 145); background: oklch(0.14 0.012 275); font-size: 0.7rem; user-select: all; }
.button-danger { margin-right: auto; color: oklch(0.78 0.1 25); } .button-danger { margin-right: auto; color: oklch(0.78 0.1 25); }
.privacy-note { display: flex; gap: 14px; margin-top: 20px; padding: 7px 14px; color: var(--muted); } .privacy-note { display: flex; gap: 14px; margin-top: 20px; padding: 7px 14px; color: var(--muted); }
.privacy-note svg { flex: 0 0 auto; margin-top: 3px; color: var(--accent-bright); } .privacy-note svg { flex: 0 0 auto; margin-top: 3px; color: var(--accent-bright); }
+1 -1
View File
@@ -29,4 +29,4 @@ export interface ServerConfig {
} }
export type ConnectionStatus = "demo" | "connecting" | "connected" | "error"; export type ConnectionStatus = "demo" | "connecting" | "connected" | "error";
export type View = "home" | "albums" | "artists" | "favorites" | "settings"; export type View = "home" | "albums" | "songs" | "artists" | "favorites" | "settings";