mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-26 09:07:41 +00:00
Compare commits
17 Commits
app-v1.34.10
...
v1.34.11
| Author | SHA1 | Date | |
|---|---|---|---|
| 76da684c8d | |||
| 795701a447 | |||
| 0a14123a0b | |||
| 7cd03248c8 | |||
| 4c6fc4d6d5 | |||
| 798812d5ef | |||
| 9f4c7382aa | |||
| d34de09673 | |||
| 1ff4f1ef0f | |||
| 9a9be253e3 | |||
| 38e59d7a5e | |||
| 915f0143f7 | |||
| e0bb02b65e | |||
| 0d5011c5be | |||
| 34d899806a | |||
| 928cdba17a | |||
| 17a5c92174 |
@@ -125,7 +125,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev squashfs-tools
|
||||
libasound2-dev squashfs-tools cmake
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
+36
-3
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.34.11] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Opus audio playback** *(Issue [#180](https://github.com/Psychotoxical/psysonic/issues/180), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#183](https://github.com/Psychotoxical/psysonic/pull/183))*: Psysonic can now decode Opus audio natively via `symphonia-adapter-libopus`, which bundles and compiles libopus from source. Previously `.opus` files were sent to the server for transcoding — a workaround that never worked reliably. Native decoding is now used directly; the server is no longer involved. Note: building from source requires `cmake` to be installed (see README).
|
||||
|
||||
- **Device Sync — synchronise your library to USB and SD card players** *(Issue [#161](https://github.com/Psychotoxical/psysonic/issues/161), by [@Psychotoxical](https://github.com/Psychotoxical))*: A fully overhauled Device Sync page lets you copy music from your Navidrome library to any mounted USB drive or SD card. Browse albums via live search (300 ms debounce) or a random album selection. Choose a filename template (Artist/Album/Track format), pick a target folder, and review a pre-sync summary showing files to add, files to delete, and available space — including a warning if the device would run out of space after accounting for pending deletions. Already-synced files are detected and skipped automatically so incremental syncs are fast.
|
||||
|
||||
- **3 visual toggles** *(contributed by [@kveld9](https://github.com/kveld9), PR [#181](https://github.com/Psychotoxical/psysonic/pull/181))*: Three new toggles in Settings → Appearance:
|
||||
- **Cover art background** — enables/disables the blurred album art background in Album Detail and the Hero section.
|
||||
- **Playlist cover photo** — shows/hides the cover collage at the top of Playlist Detail pages.
|
||||
- **Show bitrate badge** — toggles the bitrate label displayed on tracks in the queue and track lists.
|
||||
|
||||
- **8 community themes** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: A new **Community** theme group appears directly below Psysonic Themes in the Theme Picker, containing eight new themes: **AMOLED Black Pure** (pure black for OLED), **Monochrome Dark** (grayscale), **Amber Night** (warm golden amber), **Phosphor Green** (classic terminal green), **Midnight Blue** (deep blue), **Rose Dark** (pink/rose accents), **Sepia Dark** (warm cream sepia), and **Ice Blue** (cool cyan). Psysonic now ships with 75 themes across 9 groups.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HTTPS streaming failures and server URL trailing slash** *(Issue [#178](https://github.com/Psychotoxical/psysonic/issues/178), by [@Psychotoxical](https://github.com/Psychotoxical) with fix ported from PR [#179](https://github.com/Psychotoxical/psysonic/pull/179) by [@kveld9](https://github.com/kveld9))*: Two bugs that broke HTTPS server connections are now fixed. A trailing slash in the configured server URL caused double-slash stream URLs (`//rest/stream.view`) that reverse proxies like Caddy would reject, and also caused album browsing to return 0 results. Additionally, `reqwest` now loads the OS native certificate store alongside Mozilla's root store — fixing HTTPS streaming failures when the server certificate is signed by a local CA (e.g. Caddy's internal CA) that is trusted in the system keychain but not in Mozilla's bundle.
|
||||
|
||||
- **Server display in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The server list in Settings → Servers now shows the URL and username on separate lines instead of a single truncated `username@url` string. Protocol prefixes (`http://`, `https://`) are stripped for cleaner display. HTTPS connections show a green lock icon.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Waveform seekbar — live theme updates** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: The canvas-based seekbar now listens for `data-theme` attribute changes via `MutationObserver` and redraws immediately with the new theme colours. Switching themes no longer requires an app restart to update the waveform.
|
||||
|
||||
---
|
||||
|
||||
*Thank you to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for implementing native Opus decoding — a long-requested feature that finally makes `.opus` libraries fully playable.*
|
||||
*[@kveld9](https://github.com/kveld9) for three PRs in one release: the SSL/trailing-slash fix, visual customisation toggles, and eight new community themes with a live waveform update fix.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.10] - 2026-04-13
|
||||
|
||||
### Added
|
||||
@@ -17,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Folder Browser — per-column filter & Shift+Enter queue append** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#165](https://github.com/Psychotoxical/psysonic/pull/165))*: Press Ctrl+F to open a filter field for the active Folder Browser column. Focus hands off cleanly between the filter input and the row list. Clearing a parent-column selection clears all right-side filters automatically. Press Shift+Enter on a filtered track list to **append** the visible tracks to the queue without replacing it.
|
||||
|
||||
- **Keybindings — in-app modifier chords** *(contributed by [@kveld9](https://github.com/kveld9), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 0–1 progress fraction.
|
||||
- **Keybindings — in-app modifier chords** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 0–1 progress fraction.
|
||||
|
||||
- **Playlist management enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#168](https://github.com/Psychotoxical/psysonic/pull/168))*: Multi-select context-menu actions for Albums, Artists, and Playlists now include a bulk **Add to Playlist** submenu. The sidebar playlist section is now collapsible. The Artists page gains infinite scroll via `IntersectionObserver`. Submenus flip upward automatically when they would overflow the viewport bottom. A **Remove from Playlist** entry is now available in the Playlist Detail context menu.
|
||||
|
||||
@@ -36,9 +69,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
---
|
||||
|
||||
*Special thanks to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for two significant Folder Browser improvements and the Linux audio stability fixes — three PRs in one release cycle, remarkable.*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for the significant Folder Browser improvements, the modifier-chord keybindings and and the Linux audio stability fixes — four PRs in one release cycle, remarkable.*
|
||||
*[@kilyabin](https://github.com/kilyabin) for continuing to hunt down no-compositing performance issues.*
|
||||
*[@kveld9](https://github.com/kveld9) for the modifier-chord keybindings and the playlist management overhaul.*
|
||||
*[@kveld9](https://github.com/kveld9) for the playlist management overhaul.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -169,6 +169,10 @@ If you want to build Psysonic from source or contribute to the project:
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v18+)
|
||||
- [Rust](https://www.rust-lang.org/) (v1.75+)
|
||||
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
|
||||
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
|
||||
- macOS: `brew install cmake`
|
||||
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
|
||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
|
||||
|
||||
### Setup
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.9",
|
||||
"version": "1.34.11-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.9",
|
||||
"version": "1.34.11-dev",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -2278,9 +2278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.11-dev",
|
||||
"version": "1.34.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+178
-40
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
@@ -39,7 +39,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
@@ -327,7 +327,7 @@ version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
@@ -371,9 +371,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -475,7 +475,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -619,6 +619,15 @@ dependencies = [
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.24.1"
|
||||
@@ -729,7 +738,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types 0.2.0",
|
||||
"foreign-types 0.5.0",
|
||||
@@ -753,7 +762,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
@@ -1051,7 +1060,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -1721,7 +1730,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1983,14 +1992,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.8"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@@ -2148,7 +2158,7 @@ version = "1.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"byteorder",
|
||||
"flate2",
|
||||
]
|
||||
@@ -2412,7 +2422,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -2753,7 +2763,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
@@ -2767,7 +2777,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
@@ -2907,7 +2917,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2920,7 +2930,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2931,7 +2941,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2959,7 +2969,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2972,7 +2982,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2983,7 +2993,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -2995,7 +3005,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -3007,7 +3017,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3065,12 +3075,27 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "opusic-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3280fe5b6f97ac1a35a0ac003e2fb0b92f8e4bdf2b2057e1bf9b87acca5696"
|
||||
dependencies = [
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3405,7 +3430,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -3558,7 +3583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.11-dev"
|
||||
version = "1.34.11"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -3574,6 +3599,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -3803,7 +3830,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3890,6 +3917,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4036,7 +4064,7 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
@@ -4049,7 +4077,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
@@ -4070,6 +4098,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -4082,9 +4122,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.11"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4112,6 +4152,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -4169,6 +4218,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"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]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4193,7 +4265,7 @@ version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cssparser 0.36.0",
|
||||
"derive_more 2.1.1",
|
||||
"log",
|
||||
@@ -4687,6 +4759,17 @@ dependencies = [
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-adapter-libopus"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d17450685dda0e87467eddf3e0f9c0b2a1707fc5c3234c111f70d46c6e4494"
|
||||
dependencies = [
|
||||
"log",
|
||||
"opusic-sys",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
@@ -4875,6 +4958,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4894,7 +4988,7 @@ version = "0.34.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.25.0",
|
||||
@@ -5200,7 +5294,7 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5389,7 +5483,7 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -5455,9 +5549,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.1"
|
||||
version = "1.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -5628,7 +5722,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -6059,7 +6153,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
@@ -6251,6 +6345,16 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -6293,6 +6397,18 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -6343,6 +6459,17 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
@@ -6365,6 +6492,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
@@ -6883,7 +7021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.11-dev"
|
||||
version = "1.34.11"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -32,7 +32,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
@@ -45,7 +45,9 @@ discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
+15
-4
@@ -1,5 +1,5 @@
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
#[cfg(unix)]
|
||||
@@ -13,7 +13,7 @@ use rodio::source::UniformSourceIterator;
|
||||
use serde::Serialize;
|
||||
use symphonia::core::{
|
||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||
codecs::{CodecRegistry, DecoderOptions, CODEC_TYPE_NULL},
|
||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||||
meta::MetadataOptions,
|
||||
@@ -682,11 +682,22 @@ impl Drop for RadioLiveState {
|
||||
// [features]
|
||||
// fdk-aac = ["dep:symphonia-adapter-fdk-aac"]
|
||||
|
||||
/// Symphonia’s default codec set for our enabled features, plus Opus via libopus.
|
||||
fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
fn try_make_radio_decoder(
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
opts: &DecoderOptions,
|
||||
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
|
||||
symphonia::default::get_codecs().make(params, opts)
|
||||
psysonic_codec_registry().make(params, opts)
|
||||
}
|
||||
|
||||
// ── Async HTTP Download Task ──────────────────────────────────────────────────
|
||||
@@ -980,7 +991,7 @@ impl SizedDecoder {
|
||||
.zip(track.codec_params.n_frames)
|
||||
.map(|(base, frames)| base.calc_time(frames));
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
let mut decoder = psysonic_codec_registry()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| {
|
||||
eprintln!("[psysonic] codec init failed: {e}");
|
||||
|
||||
@@ -1349,6 +1349,691 @@ async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Device Sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Information about a single mounted removable drive.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct RemovableDrive {
|
||||
name: String,
|
||||
mount_point: String,
|
||||
available_space: u64,
|
||||
total_space: u64,
|
||||
file_system: String,
|
||||
is_removable: bool,
|
||||
}
|
||||
|
||||
/// Returns all currently mounted removable drives.
|
||||
/// On Linux these are typically USB sticks / SD cards under /media or /run/media.
|
||||
/// On macOS they appear under /Volumes. On Windows they are separate drive letters.
|
||||
#[tauri::command]
|
||||
fn get_removable_drives() -> Vec<RemovableDrive> {
|
||||
use sysinfo::Disks;
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
disks
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|d| d.is_removable())
|
||||
.map(|d| RemovableDrive {
|
||||
name: d.name().to_string_lossy().to_string(),
|
||||
mount_point: d.mount_point().to_string_lossy().to_string(),
|
||||
available_space: d.available_space(),
|
||||
total_space: d.total_space(),
|
||||
file_system: d.file_system().to_string_lossy().to_string(),
|
||||
is_removable: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Checks whether `path` sits on top of an active mount point (i.e. not the root
|
||||
/// filesystem). This prevents accidentally writing to `/media/usb` after the
|
||||
/// USB drive has been unmounted — at that point the path would fall through to `/`
|
||||
/// and fill the root partition.
|
||||
fn is_path_on_mounted_volume(path: &std::path::Path) -> bool {
|
||||
use sysinfo::Disks;
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let canonical = match path.canonicalize() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false, // path doesn't exist or isn't accessible
|
||||
};
|
||||
let canonical_str = canonical.to_string_lossy();
|
||||
// Find the longest mount-point prefix that matches this path.
|
||||
// Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback.
|
||||
let mut best_len: usize = 0;
|
||||
for disk in disks.list() {
|
||||
let mp = disk.mount_point().to_string_lossy().to_string();
|
||||
// Skip root mount points
|
||||
if mp == "/" || (mp.len() == 3 && mp.ends_with(":\\")) {
|
||||
continue;
|
||||
}
|
||||
if canonical_str.starts_with(&mp) && mp.len() > best_len {
|
||||
best_len = mp.len();
|
||||
}
|
||||
}
|
||||
best_len > 0
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct TrackSyncInfo {
|
||||
id: String,
|
||||
url: String,
|
||||
suffix: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
title: String,
|
||||
#[serde(rename = "trackNumber")]
|
||||
track_number: Option<u32>,
|
||||
#[serde(rename = "discNumber")]
|
||||
disc_number: Option<u32>,
|
||||
year: Option<u32>,
|
||||
}
|
||||
|
||||
/// Summary returned by `sync_batch_to_device` after all tracks are processed.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct SyncBatchResult {
|
||||
done: u32,
|
||||
skipped: u32,
|
||||
failed: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SyncTrackResult {
|
||||
path: String,
|
||||
skipped: bool,
|
||||
}
|
||||
|
||||
/// Replaces characters that are invalid in file/directory names on Windows and
|
||||
/// most Unix filesystems with an underscore. Also trims leading/trailing dots
|
||||
/// and spaces which cause issues on Windows.
|
||||
fn sanitize_path_component(s: &str) -> String {
|
||||
const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let sanitized: String = s
|
||||
.chars()
|
||||
.map(|c| if INVALID.contains(&c) || c.is_control() { '_' } else { c })
|
||||
.collect();
|
||||
sanitized.trim_matches(|c| c == '.' || c == ' ').to_string()
|
||||
}
|
||||
|
||||
/// Evaluates `template` by substituting `{artist}`, `{album}`, `{title}`,
|
||||
/// `{track_number}`, `{disc_number}`, `{year}` with sanitized values from `track`.
|
||||
fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String {
|
||||
let track_number = track.track_number
|
||||
.map(|n| format!("{:02}", n))
|
||||
.unwrap_or_default();
|
||||
let disc_number = track.disc_number
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let year = track.year
|
||||
.map(|y| y.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
template
|
||||
.replace("{artist}", &sanitize_path_component(&track.artist))
|
||||
.replace("{album}", &sanitize_path_component(&track.album))
|
||||
.replace("{title}", &sanitize_path_component(&track.title))
|
||||
.replace("{track_number}", &track_number)
|
||||
.replace("{disc_number}", &disc_number)
|
||||
.replace("{year}", &year)
|
||||
}
|
||||
|
||||
/// Downloads a single track to a USB/SD device using the configured filename template.
|
||||
/// Emits `device:sync:progress` events with `{ jobId, trackId, status, path? }`.
|
||||
#[tauri::command]
|
||||
async fn sync_track_to_device(
|
||||
track: TrackSyncInfo,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
job_id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncTrackResult, String> {
|
||||
let relative = apply_device_sync_template(&template, &track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
let dest_path = std::path::Path::new(&dest_dir).join(&file_name);
|
||||
let path_str = dest_path.to_string_lossy().to_string();
|
||||
|
||||
if dest_path.exists() {
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
|
||||
}));
|
||||
return Ok(SyncTrackResult { path: path_str, skipped: true });
|
||||
}
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&track.url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
let msg = format!("HTTP {}", response.status().as_u16());
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "error", "error": msg,
|
||||
}));
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "error", "error": e,
|
||||
}));
|
||||
return Err(e);
|
||||
}
|
||||
tokio::fs::rename(&part_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "done", "path": path_str,
|
||||
}));
|
||||
Ok(SyncTrackResult { path: path_str, skipped: false })
|
||||
}
|
||||
|
||||
/// Computes the expected file paths for a batch of tracks using the given template,
|
||||
/// without downloading anything. Used by the cleanup flow to find orphans.
|
||||
#[tauri::command]
|
||||
fn compute_sync_paths(
|
||||
tracks: Vec<TrackSyncInfo>,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
) -> Vec<String> {
|
||||
tracks.iter().map(|track| {
|
||||
let relative = apply_device_sync_template(&template, track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
std::path::Path::new(&dest_dir)
|
||||
.join(&file_name)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Lists all files (recursively) under `dir`. Returns `"VOLUME_NOT_FOUND"` if
|
||||
/// the directory does not exist (e.g. USB was unplugged).
|
||||
#[tauri::command]
|
||||
async fn list_device_dir_files(dir: String) -> Result<Vec<String>, String> {
|
||||
let root = std::path::PathBuf::from(&dir);
|
||||
if !root.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root];
|
||||
while let Some(current) = stack.pop() {
|
||||
let mut rd = match tokio::fs::read_dir(¤t).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let path = entry.path();
|
||||
// Skip hidden dirs (e.g. .Trash-1000, .Ventoy, .fseventsd)
|
||||
let is_hidden = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false);
|
||||
if is_hidden { continue; }
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Deletes a file from the device and prunes empty parent directories
|
||||
/// (up to 2 levels: album folder, then artist folder).
|
||||
#[tauri::command]
|
||||
async fn delete_device_file(path: String) -> Result<(), String> {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
if p.exists() {
|
||||
tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?;
|
||||
prune_empty_parents(&p, 2).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune empty parent directories up to `levels` levels above `file_path`.
|
||||
async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) {
|
||||
let mut current = file_path.parent().map(|d| d.to_path_buf());
|
||||
for _ in 0..levels {
|
||||
let Some(dir) = current else { break };
|
||||
let is_empty = std::fs::read_dir(&dir)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = tokio::fs::remove_dir(&dir).await;
|
||||
current = dir.parent().map(|d| d.to_path_buf());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SubsonicAuthPayload {
|
||||
base_url: String,
|
||||
u: String,
|
||||
t: String,
|
||||
s: String,
|
||||
v: String,
|
||||
c: String,
|
||||
f: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeviceSyncSourcePayload {
|
||||
#[serde(rename = "type")]
|
||||
source_type: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncDeltaResult {
|
||||
add_bytes: u64,
|
||||
add_count: u32,
|
||||
del_bytes: u64,
|
||||
del_count: u32,
|
||||
available_bytes: u64,
|
||||
tracks: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
async fn fetch_subsonic_songs(
|
||||
client: &reqwest::Client,
|
||||
auth: &SubsonicAuthPayload,
|
||||
endpoint: &str,
|
||||
id: &str,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let url = format!("{}/{}", auth.base_url, endpoint);
|
||||
let query = vec![
|
||||
("u", auth.u.as_str()),
|
||||
("t", auth.t.as_str()),
|
||||
("s", auth.s.as_str()),
|
||||
("v", auth.v.as_str()),
|
||||
("c", auth.c.as_str()),
|
||||
("f", auth.f.as_str()),
|
||||
("id", id),
|
||||
];
|
||||
let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?;
|
||||
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let root = json.get("subsonic-response").ok_or("No subsonic-response".to_string())?;
|
||||
let songs = if endpoint == "getAlbum.view" {
|
||||
root.get("album").and_then(|a| a.get("song"))
|
||||
} else if endpoint == "getPlaylist.view" {
|
||||
root.get("playlist").and_then(|p| p.get("entry"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(arr) = songs.and_then(|s| s.as_array()) {
|
||||
return Ok(arr.clone());
|
||||
} else if let Some(obj) = songs.and_then(|s| s.as_object()) {
|
||||
return Ok(vec![serde_json::Value::Object(obj.clone())]);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn calculate_sync_payload(
|
||||
sources: Vec<DeviceSyncSourcePayload>,
|
||||
deletion_ids: Vec<String>,
|
||||
auth: SubsonicAuthPayload,
|
||||
target_dir: String,
|
||||
template: String,
|
||||
) -> Result<SyncDeltaResult, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut add_bytes = 0;
|
||||
let mut add_count = 0;
|
||||
let mut del_bytes = 0;
|
||||
let mut del_count = 0;
|
||||
|
||||
let mut sync_tracks = Vec::new();
|
||||
let (mut del_sources, mut add_sources) = (Vec::new(), Vec::new());
|
||||
for s in sources {
|
||||
if deletion_ids.contains(&s.id) {
|
||||
del_sources.push(s);
|
||||
} else {
|
||||
add_sources.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for source in add_sources {
|
||||
let auth_clone = SubsonicAuthPayload {
|
||||
base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(),
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let mut res_tracks = Vec::new();
|
||||
if source.source_type == "album" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
} else if source.source_type == "playlist" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||
} else if source.source_type == "artist" {
|
||||
let url = format!("{}/getArtist.view", auth_clone.base_url);
|
||||
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
|
||||
if let Ok(re) = cli.get(&url).query(&query).send().await {
|
||||
if let Ok(js) = re.json::<serde_json::Value>().await {
|
||||
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
|
||||
let arr = root.as_array().map(|a| a.clone()).unwrap_or_else(|| {
|
||||
root.as_object().map(|o| vec![serde_json::Value::Object(o.clone())]).unwrap_or_else(|| vec![])
|
||||
});
|
||||
for al in arr {
|
||||
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await {
|
||||
res_tracks.extend(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res_tracks
|
||||
}));
|
||||
}
|
||||
|
||||
let mut del_handles = Vec::new();
|
||||
for source in del_sources {
|
||||
let auth_clone = SubsonicAuthPayload {
|
||||
base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(),
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
del_handles.push(tokio::spawn(async move {
|
||||
let mut res_tracks = Vec::new();
|
||||
if source.source_type == "album" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
} else if source.source_type == "playlist" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||
}
|
||||
res_tracks
|
||||
}));
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for handle in handles {
|
||||
if let Ok(ts) = handle.await {
|
||||
for track in ts {
|
||||
if let Some(tid) = track.get("id").and_then(|i| i.as_str()) {
|
||||
if !seen.contains(tid) {
|
||||
seen.insert(tid.to_string());
|
||||
// Build the expected path and skip files already present on device.
|
||||
let already_exists = {
|
||||
let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3");
|
||||
let sync_info = TrackSyncInfo {
|
||||
id: tid.to_string(),
|
||||
url: String::new(),
|
||||
suffix: suffix.to_string(),
|
||||
artist: track.get("artist").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
disc_number: track.get("discNumber").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
year: track.get("year").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
};
|
||||
let relative = apply_device_sync_template(&template, &sync_info);
|
||||
let file_name = format!("{}.{}", relative, suffix);
|
||||
std::path::Path::new(&target_dir).join(&file_name).exists()
|
||||
};
|
||||
if !already_exists {
|
||||
add_count += 1;
|
||||
let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| {
|
||||
track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8
|
||||
});
|
||||
add_bytes += size;
|
||||
sync_tracks.push(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for handle in del_handles {
|
||||
if let Ok(ts) = handle.await {
|
||||
for track in ts {
|
||||
del_count += 1;
|
||||
let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| {
|
||||
track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8
|
||||
});
|
||||
del_bytes += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut available_bytes = 0;
|
||||
for drive in get_removable_drives() {
|
||||
if target_dir.starts_with(&drive.mount_point) {
|
||||
available_bytes = drive.available_space;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SyncDeltaResult {
|
||||
add_bytes, add_count, del_bytes, del_count, available_bytes, tracks: sync_tracks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Downloads a batch of tracks to a USB/SD device with controlled concurrency.
|
||||
/// At most 2 parallel writes run simultaneously to prevent I/O choking on USB.
|
||||
/// Emits throttled `device:sync:progress` events (max once per 500ms) and a
|
||||
/// final `device:sync:complete` event with the summary.
|
||||
#[tauri::command]
|
||||
async fn sync_batch_to_device(
|
||||
tracks: Vec<TrackSyncInfo>,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
job_id: String,
|
||||
expected_bytes: u64,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncBatchResult, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
let dest_root = std::path::PathBuf::from(&dest_dir);
|
||||
if !dest_root.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
// Safety: verify dest_dir is on an actual mounted volume, not the root FS.
|
||||
// This catches the case where a USB drive was unmounted but the empty
|
||||
// mount-point directory still exists — writing there fills the root partition.
|
||||
if !is_path_on_mounted_volume(&dest_root) {
|
||||
return Err("NOT_MOUNTED_VOLUME".to_string());
|
||||
}
|
||||
|
||||
// Safety: Ensure target logic hasn't exceeded physical volume capacities securely stopping dead bytes natively.
|
||||
let drives = get_removable_drives();
|
||||
let dest_canon = dest_root.canonicalize().unwrap_or_else(|_| dest_root.clone());
|
||||
let dest_str = dest_canon.to_string_lossy();
|
||||
|
||||
for drive in drives {
|
||||
if dest_str.starts_with(&drive.mount_point) {
|
||||
// Buffer of ~10 MB padding boundary natively mapped
|
||||
if expected_bytes > drive.available_space.saturating_sub(10_000_000) {
|
||||
return Err(format!("NOT_ENOUGH_SPACE"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared reqwest client — reused across all downloads.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Concurrency limiter: max 2 parallel USB writes.
|
||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
|
||||
|
||||
// Counters.
|
||||
let done = std::sync::Arc::new(AtomicU32::new(0));
|
||||
let skipped = std::sync::Arc::new(AtomicU32::new(0));
|
||||
let failed = std::sync::Arc::new(AtomicU32::new(0));
|
||||
|
||||
// Throttled event emission (max once per 500ms).
|
||||
let last_emit = std::sync::Arc::new(Mutex::new(Instant::now()));
|
||||
let total = tracks.len() as u32;
|
||||
|
||||
let mut handles = Vec::with_capacity(tracks.len());
|
||||
|
||||
for track in tracks {
|
||||
let sem = semaphore.clone();
|
||||
let cli = client.clone();
|
||||
let app2 = app.clone();
|
||||
let job = job_id.clone();
|
||||
let tmpl = template.clone();
|
||||
let dest = dest_dir.clone();
|
||||
let d = done.clone();
|
||||
let s = skipped.clone();
|
||||
let f = failed.clone();
|
||||
let le = last_emit.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.expect("semaphore closed");
|
||||
|
||||
let relative = apply_device_sync_template(&tmpl, &track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
let dest_path = std::path::Path::new(&dest).join(&file_name);
|
||||
let path_str = dest_path.to_string_lossy().to_string();
|
||||
|
||||
let status;
|
||||
if dest_path.exists() {
|
||||
s.fetch_add(1, Ordering::Relaxed);
|
||||
status = "skipped";
|
||||
} else {
|
||||
// Ensure parent directories exist.
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job, "trackId": track.id, "status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let response = match cli.get(&track.url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job, "trackId": track.id, "status": "error",
|
||||
"error": format!("HTTP {}", r.status().as_u16()),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job, "trackId": track.id, "status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job, "trackId": track.id, "status": "error",
|
||||
"error": e,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&part_path, &dest_path).await {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job, "trackId": track.id, "status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
d.fetch_add(1, Ordering::Relaxed);
|
||||
status = "done";
|
||||
}
|
||||
|
||||
// Throttled progress event — max once per 500ms.
|
||||
let should_emit = {
|
||||
let mut guard = le.lock().await;
|
||||
if guard.elapsed() >= Duration::from_millis(500) {
|
||||
*guard = Instant::now();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_emit {
|
||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job, "trackId": track.id, "status": status, "path": path_str,
|
||||
"done": d.load(Ordering::Relaxed),
|
||||
"skipped": s.load(Ordering::Relaxed),
|
||||
"failed": f.load(Ordering::Relaxed),
|
||||
"total": total,
|
||||
}));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete.
|
||||
for handle in handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
let result = SyncBatchResult {
|
||||
done: done.load(Ordering::Relaxed),
|
||||
skipped: skipped.load(Ordering::Relaxed),
|
||||
failed: failed.load(Ordering::Relaxed),
|
||||
};
|
||||
|
||||
// Final event so the frontend always sees 100%.
|
||||
let _ = app.emit("device:sync:complete", serde_json::json!({
|
||||
"jobId": job_id,
|
||||
"done": result.done,
|
||||
"skipped": result.skipped,
|
||||
"failed": result.failed,
|
||||
"total": total,
|
||||
}));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Deletes multiple files from the device in one call and prunes empty parent
|
||||
/// directories. Returns the number of files successfully deleted.
|
||||
#[tauri::command]
|
||||
async fn delete_device_files(paths: Vec<String>) -> Result<u32, String> {
|
||||
let mut deleted: u32 = 0;
|
||||
for path in &paths {
|
||||
let p = std::path::PathBuf::from(path);
|
||||
if p.exists() {
|
||||
if tokio::fs::remove_file(&p).await.is_ok() {
|
||||
deleted += 1;
|
||||
prune_empty_parents(&p, 2).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Builds and returns a new system-tray icon with all menu items and event handlers.
|
||||
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
|
||||
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
@@ -1691,6 +2376,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
calculate_sync_payload,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
no_compositing_mode,
|
||||
@@ -1738,6 +2424,13 @@ pub fn run() {
|
||||
get_hot_cache_size,
|
||||
delete_hot_cache_track,
|
||||
purge_hot_cache,
|
||||
sync_track_to_device,
|
||||
sync_batch_to_device,
|
||||
compute_sync_paths,
|
||||
list_device_dir_files,
|
||||
delete_device_file,
|
||||
delete_device_files,
|
||||
get_removable_drives,
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.11-dev",
|
||||
"version": "1.34.11",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -36,6 +36,7 @@ import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import DeviceSync from './pages/DeviceSync';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
@@ -370,6 +371,7 @@ function AppShell() {
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+11
-4
@@ -23,7 +23,7 @@ function getAuthParams(username: string, password: string) {
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
export function getClient() {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
@@ -741,11 +741,18 @@ export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSam
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
const data = await api<{ artists: { index: any } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const indices = data.artists?.index ?? [];
|
||||
return indices.flatMap(i => i.artist ?? []);
|
||||
const rawIdx = data.artists?.index;
|
||||
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
||||
const artists: SubsonicArtist[] = [];
|
||||
for (const idx of indices) {
|
||||
const rawArt = idx.artist;
|
||||
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
||||
artists.push(...arr);
|
||||
}
|
||||
return artists;
|
||||
}
|
||||
|
||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
|
||||
@@ -125,6 +126,7 @@ export default function AlbumHeader({
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -138,14 +140,16 @@ export default function AlbumHeader({
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
<>
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -19,11 +20,11 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate}`);
|
||||
return parts.join(' ');
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
@@ -108,6 +109,7 @@ const TrackRow = React.memo(function TrackRow({
|
||||
}: TrackRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
// Fine-grained: only re-renders when THIS row's selection boolean flips.
|
||||
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
||||
const isActive = currentTrackId === song.id;
|
||||
@@ -192,8 +194,8 @@ const TrackRow = React.memo(function TrackRow({
|
||||
case 'format':
|
||||
return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
{(song.suffix || (showBitrate && song.bitRate)) && (
|
||||
<span className="track-codec">{codecLabel(song, showBitrate)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
@@ -58,6 +59,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -141,8 +143,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<HeroBg url={stableBgUrl.current} />
|
||||
<div className="hero-overlay" aria-hidden="true" />
|
||||
{enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
@@ -10,7 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle, HardDriveUpload,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -30,7 +31,8 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
|
||||
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
|
||||
deviceSync: { icon: HardDriveUpload, labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
@@ -49,6 +51,12 @@ export default function Sidebar({
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
|
||||
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
||||
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
const isSyncing = syncJobStatus === 'running';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
@@ -368,6 +376,19 @@ export default function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSyncing && (
|
||||
<div
|
||||
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
|
||||
data-tooltip-pos="right"
|
||||
>
|
||||
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||
{!isCollapsed && (
|
||||
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -93,6 +93,19 @@ export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'COMMUNITY',
|
||||
themes: [
|
||||
{ id: 'amber-night', label: 'Amber Night', bg: '#1a1410', card: '#201a12', accent: '#d4a96a' },
|
||||
{ id: 'amoled-black-pure', label: 'AMOLED Black Pure', bg: '#000000', card: '#000000', accent: '#ffffff' },
|
||||
{ id: 'ice-blue', label: 'Ice Blue', bg: '#0e1d28', card: '#132430', accent: '#7dd3e8' },
|
||||
{ id: 'midnight-blue', label: 'Midnight Blue', bg: '#0d1420', card: '#111a28', accent: '#60a5fa' },
|
||||
{ id: 'monochrome', label: 'Monochrome Dark', bg: '#161616', card: '#1c1c1c', accent: '#c0c0c0' },
|
||||
{ id: 'phosphor-green', label: 'Phosphor Green', bg: '#0d1a0d', card: '#111f11', accent: '#4ade80' },
|
||||
{ id: 'rose-dark', label: 'Rose Dark', bg: '#1a0d14', card: '#20111a', accent: '#f472b6' },
|
||||
{ id: 'sepia-dark', label: 'Sepia Dark', bg: '#1e1a14', card: '#252018', accent: '#c8b89a' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mediaplayer',
|
||||
themes: [
|
||||
|
||||
@@ -869,6 +869,17 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return () => ro.disconnect();
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Theme change observer — redraw canvas when theme changes.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => observer.disconnect();
|
||||
}, [seekbarStyle]);
|
||||
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
const seekRef = useRef(seek);
|
||||
|
||||
@@ -17,6 +17,7 @@ export const deTranslation = {
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
syncingTracks: 'Synchronisiere {{done}}/{{total}}…',
|
||||
cancelDownload: 'Download abbrechen',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
@@ -24,6 +25,7 @@ export const deTranslation = {
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Ordner-Browser',
|
||||
deviceSync: 'Gerätesync',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
expandPlaylists: 'Playlists ausklappen',
|
||||
@@ -660,6 +662,13 @@ export const deTranslation = {
|
||||
themeSchedulerNightTheme: 'Nacht-Theme',
|
||||
themeSchedulerNightStart: 'Nacht beginnt um',
|
||||
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.',
|
||||
visualOptionsTitle: 'Visuelle Optionen',
|
||||
coverArtBackground: 'Cover-Hintergrund',
|
||||
coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen',
|
||||
playlistCoverPhoto: 'Playlist-Coverfoto',
|
||||
playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht',
|
||||
showBitrate: 'Bitrate anzeigen',
|
||||
showBitrateSub: 'Audio-Bitrate in Track-Listen anzeigen',
|
||||
uiScaleTitle: 'Interface-Skalierung',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -1021,4 +1030,66 @@ export const deTranslation = {
|
||||
empty: 'Leerer Ordner',
|
||||
error: 'Laden fehlgeschlagen',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Gerätesync',
|
||||
targetFolder: 'Zielordner',
|
||||
noFolderChosen: 'Kein Ordner gewählt',
|
||||
selectDrive: 'Laufwerk auswählen…',
|
||||
refreshDrives: 'Laufwerke aktualisieren',
|
||||
noDrivesDetected: 'Keine Wechseldatenträger erkannt',
|
||||
browseManual: 'Manuell durchsuchen…',
|
||||
free: 'frei',
|
||||
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
|
||||
chooseFolder: 'Auswählen…',
|
||||
filenameTemplate: 'Dateinamen-Vorlage',
|
||||
targetDevice: 'Zielgerät',
|
||||
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
onDevice: 'Auf dem Gerät',
|
||||
addSources: 'Hinzufügen…',
|
||||
colName: 'Name',
|
||||
colType: 'Typ',
|
||||
syncResult: '{{done}} übertragen, {{skipped}} bereits aktuell ({{total}} gesamt)',
|
||||
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
|
||||
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
|
||||
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
|
||||
selectedSources: 'Ausgewählte Quellen',
|
||||
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
|
||||
clearAll: 'Alle entfernen',
|
||||
tabPlaylists: 'Wiedergabelisten',
|
||||
tabAlbums: 'Alben',
|
||||
tabArtists: 'Künstler',
|
||||
searchPlaceholder: 'Suche…',
|
||||
syncButton: 'Auf Gerät übertragen',
|
||||
actionTransfer: 'Auf Gerät übertragen',
|
||||
actionDelete: 'Vom Gerät löschen',
|
||||
actionApplyAll: 'Änderungen synchronisieren',
|
||||
templatePreview: 'Vorschau',
|
||||
cancel: 'Abbrechen',
|
||||
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
|
||||
noSources: 'Bitte mindestens eine Quelle auswählen.',
|
||||
noTracks: 'Keine Tracks in den ausgewählten Quellen gefunden.',
|
||||
fetchError: 'Fehler beim Laden der Tracks vom Server.',
|
||||
syncComplete: 'Übertragung abgeschlossen.',
|
||||
dismiss: 'Schließen',
|
||||
colStatus: 'Status',
|
||||
statusSynced: 'Synchronisiert',
|
||||
statusPending: 'Ausstehend',
|
||||
statusDeletion: 'Löschung',
|
||||
markForDeletion: 'Zur Löschung markieren',
|
||||
removeSource: 'Entfernen',
|
||||
undoDeletion: 'Löschung rückgängig',
|
||||
syncInBackground: 'Sync gestartet — du kannst die Seite verlassen.',
|
||||
syncInProgress: '{{done}} / {{total}} Tracks…',
|
||||
syncSummary: 'Sync-Zusammenfassung',
|
||||
calculating: 'Payload wird berechnet…',
|
||||
filesToAdd: 'Hinzuzufügende Dateien:',
|
||||
filesToDelete: 'Zu löschende Dateien:',
|
||||
netChange: 'Nettoänderung:',
|
||||
availableSpace: 'Verfügbarer Speicher:',
|
||||
spaceWarning: 'Warnung: Das Zielgerät hat nicht genug gemeldeten Speicherplatz.',
|
||||
proceed: 'Sync durchführen',
|
||||
notEnoughSpace: 'Nicht genug physischer Speicherplatz erkannt!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Zufallsalben',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const enTranslation = {
|
||||
collapse: 'Collapse Sidebar',
|
||||
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
syncingTracks: 'Syncing {{done}}/{{total}}…',
|
||||
cancelDownload: 'Cancel download',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
@@ -25,6 +26,7 @@ export const enTranslation = {
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
folderBrowser: 'Folder Browser',
|
||||
deviceSync: 'Device Sync',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
expandPlaylists: 'Expand playlists',
|
||||
@@ -662,6 +664,13 @@ export const enTranslation = {
|
||||
themeSchedulerNightTheme: 'Night Theme',
|
||||
themeSchedulerNightStart: 'Night Starts At',
|
||||
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.',
|
||||
visualOptionsTitle: 'Visual Options',
|
||||
coverArtBackground: 'Cover Art Background',
|
||||
coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers',
|
||||
playlistCoverPhoto: 'Playlist Cover Photo',
|
||||
playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view',
|
||||
showBitrate: 'Show Bitrate',
|
||||
showBitrateSub: 'Display audio bitrate in track listings',
|
||||
uiScaleTitle: 'Interface Scale',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -1023,4 +1032,67 @@ export const enTranslation = {
|
||||
empty: 'Empty folder',
|
||||
error: 'Failed to load',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Device Sync',
|
||||
targetFolder: 'Target Folder',
|
||||
noFolderChosen: 'No folder chosen',
|
||||
selectDrive: 'Select a drive…',
|
||||
refreshDrives: 'Refresh drives',
|
||||
noDrivesDetected: 'No removable drives detected',
|
||||
browseManual: 'Browse manually…',
|
||||
free: 'free',
|
||||
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
|
||||
chooseFolder: 'Choose…',
|
||||
filenameTemplate: 'Filename Template',
|
||||
targetDevice: 'Target Device',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
onDevice: 'Device Manager',
|
||||
addSources: 'Add…',
|
||||
colName: 'Name',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)',
|
||||
deleteFromDevice: 'Mark for deletion ({{count}})',
|
||||
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
|
||||
deleteComplete: '{{count}} item(s) removed from device.',
|
||||
selectedSources: 'Selected Sources',
|
||||
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
|
||||
clearAll: 'Clear all',
|
||||
tabPlaylists: 'Playlists',
|
||||
tabAlbums: 'Albums',
|
||||
tabArtists: 'Artists',
|
||||
searchPlaceholder: 'Search…',
|
||||
syncButton: 'Sync to Device',
|
||||
actionTransfer: 'Transfer to Device',
|
||||
actionDelete: 'Delete from Device',
|
||||
actionApplyAll: 'Apply All Changes',
|
||||
templatePreview: 'Preview',
|
||||
cancel: 'Cancel',
|
||||
noTargetDir: 'Please choose a target folder first.',
|
||||
noSources: 'Please select at least one source.',
|
||||
noTracks: 'No tracks found in the selected sources.',
|
||||
fetchError: 'Failed to fetch tracks from server.',
|
||||
syncComplete: 'Sync complete.',
|
||||
dismiss: 'Dismiss',
|
||||
statusSynced: 'Synced',
|
||||
statusPending: 'Pending',
|
||||
statusDeletion: 'Deletion',
|
||||
markForDeletion: 'Mark for deletion',
|
||||
undoDeletion: 'Undo deletion',
|
||||
removeSource: 'Remove',
|
||||
syncInBackground: 'Sync started in background — you can navigate away.',
|
||||
syncInProgress: '{{done}} / {{total}} tracks…',
|
||||
scanningDevice: 'Scanning device…',
|
||||
syncSummary: 'Sync Summary',
|
||||
calculating: 'Calculating required payload…',
|
||||
filesToAdd: 'Files to Add:',
|
||||
filesToDelete: 'Files to Delete:',
|
||||
netChange: 'Net Change:',
|
||||
availableSpace: 'Available Disk Space:',
|
||||
spaceWarning: 'Warning: Target device does not have enough reported space.',
|
||||
proceed: 'Proceed with Sync',
|
||||
notEnoughSpace: 'Not enough physical disk space detected!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Random Albums',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const esTranslation = {
|
||||
collapse: 'Colapsar Barra Lateral',
|
||||
|
||||
downloadingTracks: 'Descargando {{n}} pistas…',
|
||||
syncingTracks: 'Sincronizando {{done}}/{{total}}…',
|
||||
cancelDownload: 'Cancelar descarga',
|
||||
offlineLibrary: 'Biblioteca Offline',
|
||||
genres: 'Géneros',
|
||||
@@ -25,6 +26,7 @@ export const esTranslation = {
|
||||
mostPlayed: 'Más Reproducidos',
|
||||
radio: 'Radio por Internet',
|
||||
folderBrowser: 'Explorar Carpetas',
|
||||
deviceSync: 'Sincronizar dispositivo',
|
||||
libraryScope: 'Ámbito de biblioteca',
|
||||
allLibraries: 'Todas las bibliotecas',
|
||||
expandPlaylists: 'Expandir listas',
|
||||
@@ -663,6 +665,13 @@ export const esTranslation = {
|
||||
themeSchedulerNightTheme: 'Tema de Noche',
|
||||
themeSchedulerNightStart: 'Comienza de Noche A las',
|
||||
themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.',
|
||||
visualOptionsTitle: 'Opciones Visuales',
|
||||
coverArtBackground: 'Fondo de Portada',
|
||||
coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción',
|
||||
playlistCoverPhoto: 'Foto de Portada de Playlist',
|
||||
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
|
||||
showBitrate: 'Mostrar Bitrate',
|
||||
showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas',
|
||||
uiScaleTitle: 'Escala de Interfaz',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -1024,4 +1033,70 @@ export const esTranslation = {
|
||||
empty: 'Carpeta vacía',
|
||||
error: 'Error al cargar',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Sincronizar dispositivo',
|
||||
targetFolder: 'Carpeta de destino',
|
||||
noFolderChosen: 'Ninguna carpeta seleccionada',
|
||||
selectDrive: 'Seleccionar unidad…',
|
||||
refreshDrives: 'Actualizar unidades',
|
||||
noDrivesDetected: 'No se detectaron unidades extraíbles',
|
||||
browseManual: 'Explorar manualmente…',
|
||||
free: 'libre',
|
||||
notMountedVolume: 'El destino no está en un volumen montado. La unidad puede haberse desconectado.',
|
||||
chooseFolder: 'Elegir…',
|
||||
filenameTemplate: 'Plantilla de nombre de archivo',
|
||||
targetDevice: 'Dispositivo de destino',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Eliminar archivos fuera de la selección',
|
||||
cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.',
|
||||
confirmCleanup: '¿Eliminar {{count}} archivo(s) del dispositivo que no están en la selección actual? ¿Continuar?',
|
||||
cleanupComplete: '{{count}} archivo(s) eliminado(s) del dispositivo.',
|
||||
selectedSources: 'Fuentes seleccionadas',
|
||||
noSourcesSelected: 'No hay fuentes seleccionadas. Haz clic en elementos del navegador para agregarlos.',
|
||||
clearAll: 'Limpiar todo',
|
||||
tabPlaylists: 'Listas de reproducción',
|
||||
tabAlbums: 'Álbumes',
|
||||
tabArtists: 'Artistas',
|
||||
searchPlaceholder: 'Buscar…',
|
||||
syncButton: 'Sincronizar al dispositivo',
|
||||
actionTransfer: 'Transferir al dispositivo',
|
||||
actionDelete: 'Eliminar del dispositivo',
|
||||
actionApplyAll: 'Aplicar todos los cambios',
|
||||
templatePreview: 'Vista previa',
|
||||
cancel: 'Cancelar',
|
||||
noTargetDir: 'Por favor, elige primero una carpeta de destino.',
|
||||
noSources: 'Por favor, selecciona al menos una fuente.',
|
||||
noTracks: 'No se encontraron pistas en las fuentes seleccionadas.',
|
||||
fetchError: 'Error al obtener pistas del servidor.',
|
||||
syncComplete: 'Sincronización completada.',
|
||||
dismiss: 'Cerrar',
|
||||
colName: 'Nombre',
|
||||
colType: 'Tipo',
|
||||
colStatus: 'Estado',
|
||||
onDevice: 'Gestor de dispositivo',
|
||||
addSources: 'Agregar…',
|
||||
syncResult: '{{done}} transferido(s), {{skipped}} ya actualizado(s) ({{total}} total)',
|
||||
deleteFromDevice: 'Marcar para eliminar ({{count}})',
|
||||
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
|
||||
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
|
||||
statusSynced: 'Sincronizado',
|
||||
statusPending: 'Pendiente',
|
||||
statusDeletion: 'Eliminación',
|
||||
markForDeletion: 'Marcar para eliminar',
|
||||
removeSource: 'Eliminar',
|
||||
undoDeletion: 'Deshacer eliminación',
|
||||
syncInBackground: 'Sincronización iniciada en segundo plano — puedes navegar a otro lugar.',
|
||||
syncInProgress: '{{done}} / {{total}} pistas…',
|
||||
syncSummary: 'Resumen de sincronización',
|
||||
calculating: 'Calculando la carga útil requerida…',
|
||||
filesToAdd: 'Archivos a agregar:',
|
||||
filesToDelete: 'Archivos a eliminar:',
|
||||
netChange: 'Cambio neto:',
|
||||
availableSpace: 'Espacio disponible en disco:',
|
||||
spaceWarning: 'Advertencia: El dispositivo de destino no tiene suficiente espacio reportado.',
|
||||
proceed: 'Proceder con la sincronización',
|
||||
notEnoughSpace: '¡Espacio físico en disco insuficiente detectado!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Álbumes aleatorios',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const frTranslation = {
|
||||
expand: 'Développer la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
syncingTracks: 'Synchro {{done}}/{{total}}…',
|
||||
cancelDownload: 'Annuler le téléchargement',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
@@ -24,6 +25,7 @@ export const frTranslation = {
|
||||
mostPlayed: 'Les plus joués',
|
||||
radio: 'Radio Internet',
|
||||
folderBrowser: 'Explorateur de dossiers',
|
||||
deviceSync: 'Sync appareil',
|
||||
libraryScope: 'Portée de la bibliothèque',
|
||||
allLibraries: 'Toutes les bibliothèques',
|
||||
expandPlaylists: 'Développer les playlists',
|
||||
@@ -658,6 +660,13 @@ export const frTranslation = {
|
||||
themeSchedulerNightTheme: 'Thème de nuit',
|
||||
themeSchedulerNightStart: 'Début de la nuit',
|
||||
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.',
|
||||
visualOptionsTitle: 'Options Visuelles',
|
||||
coverArtBackground: "Fond d'Art de Poche",
|
||||
coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists",
|
||||
playlistCoverPhoto: 'Photo de Couverture de Playlist',
|
||||
playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists',
|
||||
showBitrate: 'Afficher le Débit',
|
||||
showBitrateSub: 'Afficher le débit audio dans les listes de pistes',
|
||||
uiScaleTitle: "Mise à l'échelle de l'interface",
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -1019,4 +1028,70 @@ export const frTranslation = {
|
||||
empty: 'Dossier vide',
|
||||
error: 'Échec du chargement',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Sync appareil',
|
||||
targetFolder: 'Dossier cible',
|
||||
noFolderChosen: 'Aucun dossier sélectionné',
|
||||
selectDrive: 'Sélectionner un lecteur…',
|
||||
refreshDrives: 'Actualiser les lecteurs',
|
||||
noDrivesDetected: 'Aucun lecteur amovible détecté',
|
||||
browseManual: 'Parcourir manuellement…',
|
||||
free: 'libre',
|
||||
notMountedVolume: 'La cible n\u0027est pas sur un volume monté. Le lecteur a peut-être été déconnecté.',
|
||||
chooseFolder: 'Choisir…',
|
||||
filenameTemplate: 'Modèle de nom de fichier',
|
||||
targetDevice: 'Appareil cible',
|
||||
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Supprimer les fichiers absents',
|
||||
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
|
||||
confirmCleanup: 'Supprimer {{count}} fichier(s) de l\'appareil qui ne font pas partie de la sélection actuelle. Continuer ?',
|
||||
cleanupComplete: '{{count}} fichier(s) supprimé(s) de l\'appareil.',
|
||||
selectedSources: 'Sources sélectionnées',
|
||||
noSourcesSelected: 'Aucune source sélectionnée. Cliquez sur des éléments dans le navigateur pour les ajouter.',
|
||||
clearAll: 'Tout effacer',
|
||||
tabPlaylists: 'Listes de lecture',
|
||||
tabAlbums: 'Albums',
|
||||
tabArtists: 'Artistes',
|
||||
searchPlaceholder: 'Rechercher…',
|
||||
syncButton: 'Synchroniser vers l\'appareil',
|
||||
actionTransfer: 'Transférer vers l\'appareil',
|
||||
actionDelete: 'Supprimer de l\'appareil',
|
||||
actionApplyAll: 'Appliquer toutes les modifications',
|
||||
templatePreview: 'Aperçu',
|
||||
cancel: 'Annuler',
|
||||
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
|
||||
noSources: 'Veuillez sélectionner au moins une source.',
|
||||
noTracks: 'Aucune piste trouvée dans les sources sélectionnées.',
|
||||
fetchError: 'Échec du chargement des pistes depuis le serveur.',
|
||||
syncComplete: 'Synchronisation terminée.',
|
||||
dismiss: 'Fermer',
|
||||
colName: 'Nom',
|
||||
colType: 'Type',
|
||||
colStatus: 'Statut',
|
||||
onDevice: 'Gestionnaire d\'appareil',
|
||||
addSources: 'Ajouter…',
|
||||
syncResult: '{{done}} transféré(s), {{skipped}} déjà à jour ({{total}} au total)',
|
||||
deleteFromDevice: 'Marquer pour suppression ({{count}})',
|
||||
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
|
||||
deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.',
|
||||
statusSynced: 'Synchronisé',
|
||||
statusPending: 'En attente',
|
||||
statusDeletion: 'Suppression',
|
||||
markForDeletion: 'Marquer pour suppression',
|
||||
removeSource: 'Supprimer',
|
||||
undoDeletion: 'Annuler la suppression',
|
||||
syncInBackground: 'Sync démarré en arrière-plan — vous pouvez naviguer ailleurs.',
|
||||
syncInProgress: '{{done}} / {{total}} pistes…',
|
||||
syncSummary: 'Résumé de la synchronisation',
|
||||
calculating: 'Calcul de la charge utile…',
|
||||
filesToAdd: 'Fichiers à ajouter :',
|
||||
filesToDelete: 'Fichiers à supprimer :',
|
||||
netChange: 'Variation nette :',
|
||||
availableSpace: 'Espace disque disponible :',
|
||||
spaceWarning: 'Attention : L\'appareil cible ne dispose pas d\'assez d\'espace signalé.',
|
||||
proceed: 'Procéder à la synchronisation',
|
||||
notEnoughSpace: 'Espace disque physique insuffisant détecté !',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Albums aléatoires',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const nbTranslation = {
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
syncingTracks: 'Synkroniserer {{done}}/{{total}}…',
|
||||
cancelDownload: 'Avbryt nedlasting',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
@@ -24,6 +25,7 @@ export const nbTranslation = {
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
folderBrowser: 'Mappeleser',
|
||||
deviceSync: 'Enhetssynk',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
expandPlaylists: 'Utvid spillelister',
|
||||
@@ -657,6 +659,13 @@ export const nbTranslation = {
|
||||
themeSchedulerNightTheme: 'Natttema',
|
||||
themeSchedulerNightStart: 'Natt starter kl.',
|
||||
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
|
||||
visualOptionsTitle: 'Visuelle Alternativer',
|
||||
coverArtBackground: 'Cover-bakgrunn',
|
||||
coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter',
|
||||
playlistCoverPhoto: 'Playlist-coverfoto',
|
||||
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
|
||||
showBitrate: 'Vis Bitrate',
|
||||
showBitrateSub: 'Vis audio-bitrate i sporlister',
|
||||
uiScaleTitle: 'Grensesnittskala',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -1018,4 +1027,70 @@ export const nbTranslation = {
|
||||
empty: 'Tom mappe',
|
||||
error: 'Kunne ikke laste',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Enhetssynk',
|
||||
targetFolder: 'Målmappe',
|
||||
noFolderChosen: 'Ingen mappe valgt',
|
||||
selectDrive: 'Velg en stasjon…',
|
||||
refreshDrives: 'Oppdater stasjoner',
|
||||
noDrivesDetected: 'Ingen flyttbare stasjoner oppdaget',
|
||||
browseManual: 'Bla manuelt…',
|
||||
free: 'ledig',
|
||||
notMountedVolume: 'Målet er ikke på en montert stasjon. Enheten kan ha blitt koblet fra.',
|
||||
chooseFolder: 'Velg…',
|
||||
filenameTemplate: 'Filnavnmal',
|
||||
targetDevice: 'Målenhet',
|
||||
templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Fjern filer som ikke er i utvalget',
|
||||
cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.',
|
||||
confirmCleanup: 'Slette {{count}} fil(er) fra enheten som ikke er i det gjeldende utvalget. Fortsette?',
|
||||
cleanupComplete: '{{count}} fil(er) fjernet fra enheten.',
|
||||
selectedSources: 'Valgte kilder',
|
||||
noSourcesSelected: 'Ingen kilder valgt. Klikk på elementer i nettleseren for å legge dem til.',
|
||||
clearAll: 'Fjern alle',
|
||||
tabPlaylists: 'Spillelister',
|
||||
tabAlbums: 'Album',
|
||||
tabArtists: 'Artister',
|
||||
searchPlaceholder: 'Søk…',
|
||||
syncButton: 'Synkroniser til enhet',
|
||||
actionTransfer: 'Overfør til enhet',
|
||||
actionDelete: 'Slett fra enhet',
|
||||
actionApplyAll: 'Bruk alle endringer',
|
||||
templatePreview: 'Forhåndsvisning',
|
||||
cancel: 'Avbryt',
|
||||
noTargetDir: 'Velg en målmappe først.',
|
||||
noSources: 'Velg minst én kilde.',
|
||||
noTracks: 'Ingen spor funnet i de valgte kildene.',
|
||||
fetchError: 'Kunne ikke hente spor fra serveren.',
|
||||
syncComplete: 'Synkronisering fullført.',
|
||||
dismiss: 'Lukk',
|
||||
colName: 'Navn',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
onDevice: 'Enhetsbehandling',
|
||||
addSources: 'Legg til…',
|
||||
syncResult: '{{done}} overført, {{skipped}} allerede oppdatert ({{total}} totalt)',
|
||||
deleteFromDevice: 'Merk for sletting ({{count}})',
|
||||
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
|
||||
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
|
||||
statusSynced: 'Synkronisert',
|
||||
statusPending: 'Venter',
|
||||
statusDeletion: 'Sletting',
|
||||
markForDeletion: 'Merk for sletting',
|
||||
removeSource: 'Fjern',
|
||||
undoDeletion: 'Angre sletting',
|
||||
syncInBackground: 'Synkronisering startet i bakgrunnen — du kan navigere bort.',
|
||||
syncInProgress: '{{done}} / {{total}} spor…',
|
||||
syncSummary: 'Synkroniseringssammendrag',
|
||||
calculating: 'Beregner nødvendig nyttelast…',
|
||||
filesToAdd: 'Filer som skal legges til:',
|
||||
filesToDelete: 'Filer som skal slettes:',
|
||||
netChange: 'Nettoendring:',
|
||||
availableSpace: 'Tilgjengelig diskplass:',
|
||||
spaceWarning: 'Advarsel: Målenheten har ikke nok rapportert plass.',
|
||||
proceed: 'Fortsett med synkronisering',
|
||||
notEnoughSpace: 'Ikke nok fysisk diskplass oppdaget!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Tilfeldige album',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const nlTranslation = {
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
syncingTracks: 'Synchroniseren {{done}}/{{total}}…',
|
||||
cancelDownload: 'Download annuleren',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
@@ -24,6 +25,7 @@ export const nlTranslation = {
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Mappenverkenner',
|
||||
deviceSync: 'Apparaatsync',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
expandPlaylists: 'Afspeellijsten uitklappen',
|
||||
@@ -657,6 +659,13 @@ export const nlTranslation = {
|
||||
themeSchedulerNightTheme: 'Nachtthema',
|
||||
themeSchedulerNightStart: 'Nacht begint om',
|
||||
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.",
|
||||
visualOptionsTitle: 'Visuele Opties',
|
||||
coverArtBackground: 'Cover Achtergrond',
|
||||
coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers',
|
||||
playlistCoverPhoto: 'Playlist Coverfoto',
|
||||
playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave',
|
||||
showBitrate: 'Toon Bitrate',
|
||||
showBitrateSub: 'Toon audio bitrate in tracklijsten',
|
||||
uiScaleTitle: 'Interface schaal',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -1016,4 +1025,70 @@ export const nlTranslation = {
|
||||
empty: 'Lege map',
|
||||
error: 'Laden mislukt',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Apparaatsync',
|
||||
targetFolder: 'Doelmap',
|
||||
noFolderChosen: 'Geen map gekozen',
|
||||
selectDrive: 'Selecteer een schijf…',
|
||||
refreshDrives: 'Schijven vernieuwen',
|
||||
noDrivesDetected: 'Geen verwijderbare schijven gedetecteerd',
|
||||
browseManual: 'Handmatig bladeren…',
|
||||
free: 'vrij',
|
||||
notMountedVolume: 'Het doel bevindt zich niet op een gekoppeld volume. De schijf is mogelijk losgekoppeld.',
|
||||
chooseFolder: 'Kiezen…',
|
||||
filenameTemplate: 'Bestandsnaamsjabloon',
|
||||
targetDevice: 'Doelapparaat',
|
||||
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
|
||||
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
|
||||
confirmCleanup: '{{count}} bestand(en) verwijderen van het apparaat die niet in de huidige selectie staan. Doorgaan?',
|
||||
cleanupComplete: '{{count}} bestand(en) van het apparaat verwijderd.',
|
||||
selectedSources: 'Geselecteerde bronnen',
|
||||
noSourcesSelected: 'Geen bronnen geselecteerd. Klik op items in de browser om ze toe te voegen.',
|
||||
clearAll: 'Alles wissen',
|
||||
tabPlaylists: 'Afspeellijsten',
|
||||
tabAlbums: 'Albums',
|
||||
tabArtists: 'Artiesten',
|
||||
searchPlaceholder: 'Zoeken…',
|
||||
syncButton: 'Synchroniseren naar apparaat',
|
||||
actionTransfer: 'Overdragen naar apparaat',
|
||||
actionDelete: 'Verwijderen van apparaat',
|
||||
actionApplyAll: 'Alle wijzigingen toepassen',
|
||||
templatePreview: 'Voorbeeld',
|
||||
cancel: 'Annuleren',
|
||||
noTargetDir: 'Kies eerst een doelmap.',
|
||||
noSources: 'Selecteer minimaal één bron.',
|
||||
noTracks: 'Geen nummers gevonden in de geselecteerde bronnen.',
|
||||
fetchError: 'Ophalen van nummers van de server mislukt.',
|
||||
syncComplete: 'Synchronisatie voltooid.',
|
||||
dismiss: 'Sluiten',
|
||||
colName: 'Naam',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
onDevice: 'Apparaatbeheer',
|
||||
addSources: 'Toevoegen…',
|
||||
syncResult: '{{done}} overgedragen, {{skipped}} al up-to-date ({{total}} totaal)',
|
||||
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
|
||||
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
|
||||
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
|
||||
statusSynced: 'Gesynchroniseerd',
|
||||
statusPending: 'In behandeling',
|
||||
statusDeletion: 'Verwijdering',
|
||||
markForDeletion: 'Markeren voor verwijdering',
|
||||
removeSource: 'Verwijderen',
|
||||
undoDeletion: 'Verwijdering ongedaan maken',
|
||||
syncInBackground: 'Sync gestart op achtergrond — u kunt navigeren.',
|
||||
syncInProgress: '{{done}} / {{total}} nummers…',
|
||||
syncSummary: 'Synchronisatieoverzicht',
|
||||
calculating: 'Vereiste payload berekenen…',
|
||||
filesToAdd: 'Te toevoegen bestanden:',
|
||||
filesToDelete: 'Te verwijderen bestanden:',
|
||||
netChange: 'Nettoverandering:',
|
||||
availableSpace: 'Beschikbare schijfruimte:',
|
||||
spaceWarning: 'Waarschuwing: Het doelapparaat heeft niet genoeg gerapporteerde ruimte.',
|
||||
proceed: 'Doorgaan met synchronisatie',
|
||||
notEnoughSpace: 'Onvoldoende fysieke schijfruimte gedetecteerd!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Willekeurige albums',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ruTranslation = {
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
syncingTracks: 'Синхронизация {{done}}/{{total}}…',
|
||||
cancelDownload: 'Отменить загрузку',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
@@ -25,6 +26,7 @@ export const ruTranslation = {
|
||||
mostPlayed: 'Популярное',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
deviceSync: 'Синхронизация устройства',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
@@ -685,6 +687,13 @@ export const ruTranslation = {
|
||||
themeSchedulerNightTheme: 'Ночная тема',
|
||||
themeSchedulerNightStart: 'Ночь начинается в',
|
||||
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
|
||||
visualOptionsTitle: 'Визуальные Настройки',
|
||||
coverArtBackground: 'Фон Обложки',
|
||||
coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
|
||||
playlistCoverPhoto: 'Обложка Плейлиста',
|
||||
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
|
||||
showBitrate: 'Показывать Битрейт',
|
||||
showBitrateSub: 'Отображать битрейт аудио в списках треков',
|
||||
uiScaleTitle: 'Масштаб интерфейса',
|
||||
uiScaleLabel: 'Масштаб',
|
||||
},
|
||||
@@ -1077,4 +1086,70 @@ export const ruTranslation = {
|
||||
empty: 'Папка пуста',
|
||||
error: 'Ошибка загрузки',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Синхронизация устройства',
|
||||
targetFolder: 'Папка назначения',
|
||||
noFolderChosen: 'Папка не выбрана',
|
||||
selectDrive: 'Выберите диск…',
|
||||
refreshDrives: 'Обновить диски',
|
||||
noDrivesDetected: 'Съёмные диски не обнаружены',
|
||||
browseManual: 'Обзор вручную…',
|
||||
free: 'свободно',
|
||||
notMountedVolume: 'Цель не находится на смонтированном томе. Диск мог быть отключён.',
|
||||
chooseFolder: 'Выбрать…',
|
||||
filenameTemplate: 'Шаблон имени файла',
|
||||
targetDevice: 'Целевое устройство',
|
||||
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Удалить файлы вне выборки',
|
||||
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
|
||||
confirmCleanup: 'Удалить {{count}} файл(ов) с устройства, не входящих в текущую выборку. Продолжить?',
|
||||
cleanupComplete: '{{count}} файл(ов) удалено с устройства.',
|
||||
selectedSources: 'Выбранные источники',
|
||||
noSourcesSelected: 'Источники не выбраны. Нажмите на элементы в браузере, чтобы добавить их.',
|
||||
clearAll: 'Очистить всё',
|
||||
tabPlaylists: 'Плейлисты',
|
||||
tabAlbums: 'Альбомы',
|
||||
tabArtists: 'Исполнители',
|
||||
searchPlaceholder: 'Поиск…',
|
||||
syncButton: 'Синхронизировать на устройство',
|
||||
actionTransfer: 'Перенести на устройство',
|
||||
actionDelete: 'Удалить с устройства',
|
||||
actionApplyAll: 'Применить все изменения',
|
||||
templatePreview: 'Предпросмотр',
|
||||
cancel: 'Отмена',
|
||||
noTargetDir: 'Сначала выберите папку назначения.',
|
||||
noSources: 'Выберите хотя бы один источник.',
|
||||
noTracks: 'В выбранных источниках не найдено треков.',
|
||||
fetchError: 'Ошибка загрузки треков с сервера.',
|
||||
syncComplete: 'Синхронизация завершена.',
|
||||
dismiss: 'Закрыть',
|
||||
colName: 'Название',
|
||||
colType: 'Тип',
|
||||
colStatus: 'Статус',
|
||||
onDevice: 'Управление устройством',
|
||||
addSources: 'Добавить…',
|
||||
syncResult: '{{done}} перенесено, {{skipped}} уже актуально ({{total}} всего)',
|
||||
deleteFromDevice: 'Пометить для удаления ({{count}})',
|
||||
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
|
||||
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
|
||||
statusSynced: 'Синхронизировано',
|
||||
statusPending: 'Ожидает',
|
||||
statusDeletion: 'Удаление',
|
||||
markForDeletion: 'Пометить для удаления',
|
||||
removeSource: 'Удалить',
|
||||
undoDeletion: 'Отменить удаление',
|
||||
syncInBackground: 'Синхронизация запущена в фоне — можно перейти в другой раздел.',
|
||||
syncInProgress: '{{done}} / {{total}} треков…',
|
||||
syncSummary: 'Сводка синхронизации',
|
||||
calculating: 'Вычисление необходимых данных…',
|
||||
filesToAdd: 'Файлы для добавления:',
|
||||
filesToDelete: 'Файлы для удаления:',
|
||||
netChange: 'Чистое изменение:',
|
||||
availableSpace: 'Доступное место на диске:',
|
||||
spaceWarning: 'Предупреждение: на целевом устройстве недостаточно сообщаемого места.',
|
||||
proceed: 'Продолжить синхронизацию',
|
||||
notEnoughSpace: 'Обнаружено недостаточно физического места на диске!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Случайные альбомы',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const zhTranslation = {
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
syncingTracks: '同步中 {{done}}/{{total}}…',
|
||||
cancelDownload: '取消下载',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
@@ -24,6 +25,7 @@ export const zhTranslation = {
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
folderBrowser: '文件夹浏览器',
|
||||
deviceSync: '设备同步',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
expandPlaylists: '展开播放列表',
|
||||
@@ -653,6 +655,13 @@ export const zhTranslation = {
|
||||
themeSchedulerNightTheme: '夜晚主题',
|
||||
themeSchedulerNightStart: '夜晚开始时间',
|
||||
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
|
||||
visualOptionsTitle: '视觉选项',
|
||||
coverArtBackground: '封面背景',
|
||||
coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景',
|
||||
playlistCoverPhoto: '播放列表封面照片',
|
||||
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
|
||||
showBitrate: '显示比特率',
|
||||
showBitrateSub: '在曲目列表中显示音频比特率',
|
||||
uiScaleTitle: '界面缩放',
|
||||
uiScaleLabel: '缩放',
|
||||
},
|
||||
@@ -1012,4 +1021,70 @@ export const zhTranslation = {
|
||||
empty: '空文件夹',
|
||||
error: '加载失败',
|
||||
},
|
||||
deviceSync: {
|
||||
title: '设备同步',
|
||||
targetFolder: '目标文件夹',
|
||||
noFolderChosen: '未选择文件夹',
|
||||
selectDrive: '选择驱动器…',
|
||||
refreshDrives: '刷新驱动器',
|
||||
noDrivesDetected: '未检测到可移动驱动器',
|
||||
browseManual: '手动浏览…',
|
||||
free: '可用',
|
||||
notMountedVolume: '目标不在已挂载的卷上。驱动器可能已断开。',
|
||||
chooseFolder: '选择…',
|
||||
filenameTemplate: '文件名模板',
|
||||
targetDevice: '目标设备',
|
||||
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
|
||||
cleanupButton: '删除不在选择范围内的文件',
|
||||
cleanupNothingToDelete: '无需删除 — 设备已同步。',
|
||||
confirmCleanup: '将从设备中删除 {{count}} 个不在当前选择范围内的文件。继续?',
|
||||
cleanupComplete: '已从设备中删除 {{count}} 个文件。',
|
||||
selectedSources: '已选来源',
|
||||
noSourcesSelected: '未选择来源。点击浏览器中的项目以添加。',
|
||||
clearAll: '全部清除',
|
||||
tabPlaylists: '播放列表',
|
||||
tabAlbums: '专辑',
|
||||
tabArtists: '艺术家',
|
||||
searchPlaceholder: '搜索…',
|
||||
syncButton: '同步到设备',
|
||||
actionTransfer: '传输到设备',
|
||||
actionDelete: '从设备删除',
|
||||
actionApplyAll: '应用所有更改',
|
||||
templatePreview: '预览',
|
||||
cancel: '取消',
|
||||
noTargetDir: '请先选择目标文件夹。',
|
||||
noSources: '请至少选择一个来源。',
|
||||
noTracks: '所选来源中未找到曲目。',
|
||||
fetchError: '从服务器加载曲目失败。',
|
||||
syncComplete: '同步完成。',
|
||||
dismiss: '关闭',
|
||||
colName: '名称',
|
||||
colType: '类型',
|
||||
colStatus: '状态',
|
||||
onDevice: '设备管理器',
|
||||
addSources: '添加…',
|
||||
syncResult: '已传输 {{done}},{{skipped}} 已是最新(共 {{total}})',
|
||||
deleteFromDevice: '标记为删除({{count}})',
|
||||
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}?',
|
||||
deleteComplete: '已从设备删除 {{count}} 个项目。',
|
||||
statusSynced: '已同步',
|
||||
statusPending: '待处理',
|
||||
statusDeletion: '删除中',
|
||||
markForDeletion: '标记为删除',
|
||||
removeSource: '移除',
|
||||
undoDeletion: '撤销删除',
|
||||
syncInBackground: '同步已在后台启动 — 您可以离开此页面。',
|
||||
syncInProgress: '{{done}} / {{total}} 首曲目…',
|
||||
syncSummary: '同步摘要',
|
||||
calculating: '正在计算所需数据…',
|
||||
filesToAdd: '要添加的文件:',
|
||||
filesToDelete: '要删除的文件:',
|
||||
netChange: '净变化:',
|
||||
availableSpace: '可用磁盘空间:',
|
||||
spaceWarning: '警告:目标设备的可用空间不足。',
|
||||
proceed: '继续同步',
|
||||
notEnoughSpace: '检测到物理磁盘空间不足!',
|
||||
liveSearch: '实时',
|
||||
randomAlbumsLabel: '随机专辑',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
HardDriveUpload, FolderOpen, Loader2,
|
||||
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock,
|
||||
ChevronRight, ChevronDown, Trash2, Undo2, Search, Usb, RefreshCw, Shuffle, Zap,
|
||||
} from 'lucide-react';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import {
|
||||
getPlaylists, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist,
|
||||
buildDownloadUrl, search as searchSubsonic,
|
||||
SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist,
|
||||
} from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
type SourceTab = 'playlists' | 'albums' | 'artists';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function uuid(): string { return crypto.randomUUID(); }
|
||||
|
||||
async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
||||
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
|
||||
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
|
||||
const { albums } = await getArtist(source.id);
|
||||
const all: SubsonicSong[] = [];
|
||||
for (const album of albums) { const { songs } = await getAlbum(album.id); all.push(...songs); }
|
||||
return all;
|
||||
}
|
||||
|
||||
function trackToSyncInfo(track: SubsonicSong, url: string) {
|
||||
return {
|
||||
id: track.id, url,
|
||||
suffix: track.suffix ?? 'mp3',
|
||||
artist: track.artist ?? '',
|
||||
album: track.album ?? '',
|
||||
title: track.title ?? '',
|
||||
trackNumber: track.track,
|
||||
discNumber: track.discNumber,
|
||||
year: track.year,
|
||||
};
|
||||
}
|
||||
|
||||
type SyncStatus = 'synced' | 'pending' | 'deletion';
|
||||
|
||||
interface RemovableDrive {
|
||||
name: string;
|
||||
mount_point: string;
|
||||
available_space: number;
|
||||
total_space: number;
|
||||
file_system: string;
|
||||
is_removable: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// ─── component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DeviceSync() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const targetDir = useDeviceSyncStore(s => s.targetDir);
|
||||
const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate);
|
||||
const sources = useDeviceSyncStore(s => s.sources);
|
||||
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
|
||||
const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion);
|
||||
const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths);
|
||||
const scanning = useDeviceSyncStore(s => s.scanning);
|
||||
const {
|
||||
setTargetDir, setFilenameTemplate, addSource, removeSource,
|
||||
clearSources, toggleChecked, setCheckedIds, markForDeletion,
|
||||
unmarkDeletion, removeSources, setDeviceFilePaths, setScanning,
|
||||
} = useDeviceSyncStore.getState();
|
||||
|
||||
const jobStatus = useDeviceSyncJobStore(s => s.status);
|
||||
const jobDone = useDeviceSyncJobStore(s => s.done);
|
||||
const jobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||
const jobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const jobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SourceTab>('albums');
|
||||
const [search, setSearch] = useState('');
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [randomAlbums, setRandomAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [albumSearchResults, setAlbumSearchResults] = useState<SubsonicAlbum[]>([]);
|
||||
const [albumSearchLoading, setAlbumSearchLoading] = useState(false);
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loadingBrowser, setLoadingBrowser] = useState(false);
|
||||
const [expandedArtistIds, setExpandedArtistIds] = useState<Set<string>>(new Set());
|
||||
const [artistAlbumsMap, setArtistAlbumsMap] = useState<Map<string, SubsonicAlbum[]>>(new Map());
|
||||
const [loadingArtistIds, setLoadingArtistIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Map source IDs → computed device paths (for status derivation)
|
||||
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// ─── Removable drive detection ──────────────────────────────────────────
|
||||
const [drives, setDrives] = useState<RemovableDrive[]>([]);
|
||||
const [drivesLoading, setDrivesLoading] = useState(false);
|
||||
|
||||
const [preSyncOpen, setPreSyncOpen] = useState(false);
|
||||
const [preSyncLoading, setPreSyncLoading] = useState(false);
|
||||
const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] });
|
||||
|
||||
const refreshDrives = useCallback(async () => {
|
||||
setDrivesLoading(true);
|
||||
try {
|
||||
const result = await invoke<RemovableDrive[]>('get_removable_drives');
|
||||
setDrives(result);
|
||||
} catch {
|
||||
setDrives([]);
|
||||
} finally {
|
||||
setDrivesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch drives on mount, then poll every 5 seconds
|
||||
useEffect(() => {
|
||||
refreshDrives();
|
||||
const interval = setInterval(refreshDrives, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshDrives]);
|
||||
|
||||
// Detect if the current targetDir is on a detected removable drive
|
||||
const activeDrive = useMemo(() => {
|
||||
if (!targetDir) return null;
|
||||
return drives.find(d => targetDir.startsWith(d.mount_point)) ?? null;
|
||||
}, [targetDir, drives]);
|
||||
|
||||
const driveDetected = activeDrive !== null;
|
||||
|
||||
const isRunning = jobStatus === 'running';
|
||||
|
||||
// ─── Device scan on mount ───────────────────────────────────────────────
|
||||
|
||||
const scanDevice = useCallback(async () => {
|
||||
if (!targetDir || sources.length === 0) {
|
||||
setDeviceFilePaths([]);
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
try {
|
||||
const files = await invoke<string[]>('list_device_dir_files', { dir: targetDir });
|
||||
setDeviceFilePaths(files);
|
||||
} catch {
|
||||
setDeviceFilePaths([]);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [targetDir, sources.length]);
|
||||
|
||||
// Scan device on mount and when targetDir changes
|
||||
useEffect(() => { scanDevice(); }, [scanDevice]);
|
||||
|
||||
// Compute expected paths for each source (for status comparison)
|
||||
useEffect(() => {
|
||||
if (!targetDir || sources.length === 0) {
|
||||
setSourcePathsMap(new Map());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
await Promise.all(sources.map(async source => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map(t => trackToSyncInfo(t, '')),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
map.set(source.id, paths);
|
||||
} catch {
|
||||
map.set(source.id, []);
|
||||
}
|
||||
}));
|
||||
if (!cancelled) setSourcePathsMap(map);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [targetDir, filenameTemplate, sources]);
|
||||
|
||||
// Derive sync status per source
|
||||
const sourceStatuses = useMemo(() => {
|
||||
const deviceSet = new Set(deviceFilePaths);
|
||||
const statuses = new Map<string, SyncStatus>();
|
||||
for (const source of sources) {
|
||||
if (pendingDeletion.includes(source.id)) {
|
||||
statuses.set(source.id, 'deletion');
|
||||
} else {
|
||||
const paths = sourcePathsMap.get(source.id) ?? [];
|
||||
const allSynced = paths.length > 0 && paths.every(p => deviceSet.has(p));
|
||||
statuses.set(source.id, allSynced ? 'synced' : 'pending');
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}, [sources, pendingDeletion, sourcePathsMap, deviceFilePaths]);
|
||||
|
||||
// ─── Desired State / Diff Logic ─────────────────────────────────────────
|
||||
|
||||
const handleToggleSource = useCallback((source: DeviceSyncSource) => {
|
||||
const isSelected = sources.some(s => s.id === source.id);
|
||||
const isPendingDeletion = pendingDeletion.includes(source.id);
|
||||
const isActuallySelected = isSelected && !isPendingDeletion;
|
||||
|
||||
if (isActuallySelected) {
|
||||
// User initiated a DE-SELECTION. Diff check against target device
|
||||
const isSynced = sourceStatuses.get(source.id) === 'synced';
|
||||
const pathsOnDisk = sourcePathsMap.get(source.id)?.filter(p => deviceFilePaths.includes(p)).length || 0;
|
||||
|
||||
if (pathsOnDisk > 0 || isSynced) {
|
||||
// Source currently has physical footprint. Stage for deletion.
|
||||
markForDeletion([source.id]);
|
||||
} else {
|
||||
// Zero physical footprint. Strip safely.
|
||||
removeSource(source.id);
|
||||
}
|
||||
} else {
|
||||
// User initiated a SELECTION.
|
||||
if (isPendingDeletion) {
|
||||
unmarkDeletion(source.id); // Cancel queued red/strikethrough state
|
||||
} else if (!isSelected) {
|
||||
addSource(source); // Trigger clean pending install state
|
||||
}
|
||||
}
|
||||
}, [sources, pendingDeletion, sourceStatuses, sourcePathsMap, deviceFilePaths, markForDeletion, removeSource, unmarkDeletion, addSource]);
|
||||
|
||||
// ─── Listen for background sync events ──────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const jobStore = useDeviceSyncJobStore.getState;
|
||||
const unlistenProgress = listen<{
|
||||
jobId: string; done: number; skipped: number; failed: number; total: number;
|
||||
}>('device:sync:progress', ({ payload }) => {
|
||||
const current = jobStore();
|
||||
if (current.jobId && payload.jobId === current.jobId) {
|
||||
useDeviceSyncJobStore.getState().updateProgress(
|
||||
payload.done, payload.skipped, payload.failed
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenComplete = listen<{
|
||||
jobId: string; done: number; skipped: number; failed: number; total: number;
|
||||
}>('device:sync:complete', ({ payload }) => {
|
||||
const current = jobStore();
|
||||
if (current.jobId && payload.jobId === current.jobId) {
|
||||
useDeviceSyncJobStore.getState().complete(
|
||||
payload.done, payload.skipped, payload.failed
|
||||
);
|
||||
showToast(
|
||||
t('deviceSync.syncResult', {
|
||||
done: payload.done, skipped: payload.skipped, total: payload.total
|
||||
}),
|
||||
5000, 'info'
|
||||
);
|
||||
// Re-scan the device after sync completes
|
||||
scanDevice();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenProgress.then(f => f());
|
||||
unlistenComplete.then(f => f());
|
||||
};
|
||||
}, [t, scanDevice]);
|
||||
|
||||
// Load browser data when tab switches
|
||||
useEffect(() => {
|
||||
setSearch('');
|
||||
if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists();
|
||||
if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums();
|
||||
if (activeTab === 'artists' && artists.length === 0) loadArtists();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
// Live album search with 300ms debounce
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'albums') return;
|
||||
const q = search.trim();
|
||||
if (!q) { setAlbumSearchResults([]); return; }
|
||||
setAlbumSearchLoading(true);
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { albums } = await searchSubsonic(q, { albumCount: 20, artistCount: 0, songCount: 0 });
|
||||
setAlbumSearchResults(albums);
|
||||
} catch {
|
||||
setAlbumSearchResults([]);
|
||||
} finally {
|
||||
setAlbumSearchLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => { clearTimeout(timer); setAlbumSearchLoading(false); };
|
||||
}, [search, activeTab]);
|
||||
|
||||
const loadPlaylists = useCallback(async () => {
|
||||
setLoadingBrowser(true);
|
||||
try { setPlaylists(await getPlaylists()); } catch { /* ignore */ }
|
||||
finally { setLoadingBrowser(false); }
|
||||
}, []);
|
||||
const loadRandomAlbums = useCallback(async () => {
|
||||
setLoadingBrowser(true);
|
||||
try { setRandomAlbums(await getAlbumList('random', 10)); } catch { /* ignore */ }
|
||||
finally { setLoadingBrowser(false); }
|
||||
}, []);
|
||||
const loadArtists = useCallback(async () => {
|
||||
setLoadingBrowser(true);
|
||||
try { setArtists(await getArtists()); } catch { /* ignore */ }
|
||||
finally { setLoadingBrowser(false); }
|
||||
}, []);
|
||||
|
||||
const toggleArtistExpand = useCallback(async (artistId: string) => {
|
||||
setExpandedArtistIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(artistId)) { next.delete(artistId); return next; }
|
||||
next.add(artistId);
|
||||
return next;
|
||||
});
|
||||
if (!artistAlbumsMap.has(artistId)) {
|
||||
setLoadingArtistIds(prev => new Set(prev).add(artistId));
|
||||
try {
|
||||
const { albums } = await getArtist(artistId);
|
||||
setArtistAlbumsMap(prev => new Map(prev).set(artistId, albums));
|
||||
} finally {
|
||||
setLoadingArtistIds(prev => { const n = new Set(prev); n.delete(artistId); return n; });
|
||||
}
|
||||
}
|
||||
}, [artistAlbumsMap]);
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]);
|
||||
const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]);
|
||||
|
||||
const handleChooseFolder = async () => {
|
||||
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
|
||||
if (sel) {
|
||||
setTargetDir(sel as string);
|
||||
// Trigger a device scan after folder change
|
||||
setTimeout(() => scanDevice(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Sync (non-blocking) ────────────────────────────────────────────────
|
||||
|
||||
const promptSyncSummary = async () => {
|
||||
if (!targetDir) { showToast(t('deviceSync.noTargetDir'), 3000, 'error'); return; }
|
||||
if (sources.length === 0){ showToast(t('deviceSync.noSources'), 3000, 'error'); return; }
|
||||
|
||||
setPreSyncLoading(true);
|
||||
setPreSyncOpen(true);
|
||||
|
||||
try {
|
||||
const { getClient } = await import('../api/subsonic');
|
||||
const { baseUrl, params } = getClient();
|
||||
const payload = await invoke<{
|
||||
addBytes: number; addCount: number; delBytes: number; delCount: number; availableBytes: number; tracks: SubsonicSong[];
|
||||
}>('calculate_sync_payload', {
|
||||
sources,
|
||||
deletionIds: pendingDeletion,
|
||||
auth: { baseUrl, ...params },
|
||||
targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
|
||||
setSyncDelta(payload);
|
||||
} catch {
|
||||
showToast(t('deviceSync.fetchError'), 3000, 'error');
|
||||
setPreSyncOpen(false);
|
||||
} finally {
|
||||
setPreSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncExecution = async () => {
|
||||
setPreSyncOpen(false);
|
||||
|
||||
// 1. Handle pending deletions first
|
||||
const deletionSources = sources.filter(s => pendingDeletion.includes(s.id));
|
||||
if (deletionSources.length > 0) {
|
||||
try {
|
||||
const allPaths: string[] = [];
|
||||
const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s)));
|
||||
const deletionTracks = trackArrays.flat();
|
||||
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: deletionTracks.map(t => trackToSyncInfo(t, '')),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
allPaths.push(...paths);
|
||||
|
||||
await invoke<number>('delete_device_files', { paths: allPaths });
|
||||
removeSources(deletionSources.map(s => s.id));
|
||||
showToast(
|
||||
t('deviceSync.deleteComplete', { count: deletionSources.length }),
|
||||
3000, 'info'
|
||||
);
|
||||
} catch {
|
||||
showToast(t('deviceSync.fetchError'), 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const allTracks = syncDelta.tracks;
|
||||
if (allTracks.length === 0) {
|
||||
scanDevice();
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = uuid();
|
||||
useDeviceSyncJobStore.getState().startSync(jobId, allTracks.length);
|
||||
|
||||
showToast(t('deviceSync.syncInBackground'), 3000, 'info');
|
||||
|
||||
invoke('sync_batch_to_device', {
|
||||
tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
jobId,
|
||||
expectedBytes: syncDelta.addBytes,
|
||||
}).catch((err: string) => {
|
||||
useDeviceSyncJobStore.getState().complete(0, 0, allTracks.length);
|
||||
if (err.includes('NOT_ENOUGH_SPACE')) {
|
||||
showToast(t('deviceSync.notEnoughSpace'), 5000, 'error');
|
||||
} else if (err === 'NOT_MOUNTED_VOLUME') {
|
||||
showToast(t('deviceSync.notMountedVolume'), 5000, 'error');
|
||||
} else {
|
||||
showToast(t('deviceSync.fetchError'), 3000, 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────
|
||||
|
||||
const handleMarkCheckedForDeletion = () => {
|
||||
if (checkedIds.length === 0) return;
|
||||
markForDeletion(checkedIds);
|
||||
};
|
||||
|
||||
const allChecked = sources.length > 0 && sources.every(s => checkedIds.includes(s.id));
|
||||
const toggleAll = () => setCheckedIds(allChecked ? [] : sources.map(s => s.id));
|
||||
|
||||
const pendingCount = Array.from(sourceStatuses.values()).filter(s => s === 'pending').length;
|
||||
const syncedCount = Array.from(sourceStatuses.values()).filter(s => s === 'synced').length;
|
||||
const deletionCount = pendingDeletion.length;
|
||||
|
||||
// ─── Dynamic action button label ────────────────────────────────────────
|
||||
const actionButtonLabel = useMemo(() => {
|
||||
if (deletionCount > 0 && pendingCount === 0) return t('deviceSync.actionDelete');
|
||||
if (pendingCount > 0 && deletionCount === 0) return t('deviceSync.actionTransfer');
|
||||
if (pendingCount > 0 && deletionCount > 0) return t('deviceSync.actionApplyAll');
|
||||
return t('deviceSync.syncButton'); // both zero — button will be disabled
|
||||
}, [pendingCount, deletionCount, t]);
|
||||
|
||||
const actionButtonDisabled =
|
||||
!targetDir ||
|
||||
sources.length === 0 ||
|
||||
isRunning ||
|
||||
(!driveDetected && !!targetDir) ||
|
||||
(pendingCount === 0 && deletionCount === 0);
|
||||
|
||||
// ─── Template preview (dummy track) ─────────────────────────────────────
|
||||
const PREVIEW_TRACK = {
|
||||
artist: 'Volker Pispers',
|
||||
album: '...Bis Neulich 2007',
|
||||
title: 'Kapitalismus',
|
||||
track_number: '01',
|
||||
disc_number: '1',
|
||||
year: '2007',
|
||||
} as const;
|
||||
|
||||
const templatePreviewText = useMemo(() => {
|
||||
try {
|
||||
const result = filenameTemplate
|
||||
.replace(/\{artist\}/g, PREVIEW_TRACK.artist)
|
||||
.replace(/\{album\}/g, PREVIEW_TRACK.album)
|
||||
.replace(/\{title\}/g, PREVIEW_TRACK.title)
|
||||
.replace(/\{track_number\}/g, PREVIEW_TRACK.track_number)
|
||||
.replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number)
|
||||
.replace(/\{year\}/g, PREVIEW_TRACK.year);
|
||||
return `${result}.mp3`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [filenameTemplate]);
|
||||
|
||||
const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: 'playlists', icon: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
|
||||
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
|
||||
{ key: 'artists', icon: <Users size={14} />, label: t('deviceSync.tabArtists') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="device-sync-page">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="device-sync-header">
|
||||
<div className="device-sync-header-title">
|
||||
<HardDriveUpload size={20} />
|
||||
<h1>{t('deviceSync.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="device-sync-config-row">
|
||||
|
||||
{/* ── Left: Template ── */}
|
||||
<div className="device-sync-template-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
|
||||
<div className="device-sync-template-input-wrap">
|
||||
<input
|
||||
className="input device-sync-template-input"
|
||||
value={filenameTemplate}
|
||||
onChange={e => setFilenameTemplate(e.target.value)}
|
||||
spellCheck={false}
|
||||
data-tooltip={t('deviceSync.templateHint')}
|
||||
data-tooltip-pos="bottom"
|
||||
/>
|
||||
{templatePreviewText && (
|
||||
<span className="device-sync-template-preview">
|
||||
{t('deviceSync.templatePreview')}: {templatePreviewText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Drive config ── */}
|
||||
<div className="device-sync-target-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.targetDevice')}</span>
|
||||
<div className="device-sync-header-config">
|
||||
<div className="device-sync-drive-layout">
|
||||
{/* Row 1: Controls */}
|
||||
<div className="device-sync-drive-controls">
|
||||
{/* Fallback manual folder picker & Refresh */}
|
||||
<button className="btn btn-ghost" onClick={handleChooseFolder} data-tooltip={t('deviceSync.browseManual')}>
|
||||
<FolderOpen size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost device-sync-refresh-btn"
|
||||
onClick={refreshDrives}
|
||||
disabled={drivesLoading}
|
||||
data-tooltip={t('deviceSync.refreshDrives')}
|
||||
>
|
||||
<RefreshCw size={18} className={drivesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown element */}
|
||||
{drives.length > 0 ? (
|
||||
<>
|
||||
<Usb size={18} className="device-sync-drive-icon" />
|
||||
<CustomSelect
|
||||
className="input device-sync-drive-select"
|
||||
value={targetDir ?? ''}
|
||||
onChange={v => {
|
||||
setTargetDir(v);
|
||||
if (v) {
|
||||
setTimeout(() => scanDevice(), 100);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('deviceSync.selectDrive') },
|
||||
...drives.map(d => ({ value: d.mount_point, label: d.name || d.mount_point }))
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="device-sync-no-drives">
|
||||
<AlertCircle size={18} />
|
||||
{t('deviceSync.noDrivesDetected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Metadata */}
|
||||
{activeDrive && (
|
||||
<div className="device-sync-drive-meta">
|
||||
{formatBytes(activeDrive.available_space)} {t('deviceSync.free')} / {formatBytes(activeDrive.total_space)} • {activeDrive.file_system}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* ── Main ── */}
|
||||
<div className="device-sync-main">
|
||||
|
||||
{/* ── Browser (left) ── */}
|
||||
<div className="device-sync-browser">
|
||||
<div className="device-sync-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`device-sync-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.icon}{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="device-sync-search-wrap">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('deviceSync.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{activeTab === 'albums' && (
|
||||
<span className="device-sync-live-badge">
|
||||
<Zap size={10} />{t('deviceSync.liveSearch')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-list">
|
||||
{(loadingBrowser || albumSearchLoading) && (
|
||||
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
|
||||
)}
|
||||
{activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && (
|
||||
<div className="device-sync-section-label">
|
||||
<Shuffle size={11} />{t('deviceSync.randomAlbumsLabel')}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'playlists' && filteredPlaylists.map(pl => (
|
||||
<BrowserRow key={pl.id} name={pl.name} meta={`${pl.songCount} tracks`}
|
||||
selected={sources.some(s => s.id === pl.id) && !pendingDeletion.includes(pl.id)}
|
||||
onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} />
|
||||
))}
|
||||
{activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => (
|
||||
<BrowserRow key={al.id} name={al.name} meta={al.artist}
|
||||
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} />
|
||||
))}
|
||||
{activeTab === 'artists' && filteredArtists.map(ar => (
|
||||
<React.Fragment key={ar.id}>
|
||||
<div className="device-sync-artist-row">
|
||||
<button
|
||||
className="device-sync-expand-btn"
|
||||
onClick={() => toggleArtistExpand(ar.id)}
|
||||
>
|
||||
{loadingArtistIds.has(ar.id)
|
||||
? <Loader2 size={13} className="spin" />
|
||||
: expandedArtistIds.has(ar.id)
|
||||
? <ChevronDown size={13} />
|
||||
: <ChevronRight size={13} />}
|
||||
</button>
|
||||
<span className="device-sync-row-name">{ar.name}</span>
|
||||
{ar.albumCount != null &&
|
||||
<span className="device-sync-row-meta">{ar.albumCount} Albums</span>}
|
||||
</div>
|
||||
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) &&
|
||||
artistAlbumsMap.get(ar.id)!.map(al => (
|
||||
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
|
||||
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
||||
indent
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} />
|
||||
))
|
||||
}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Device Manager (right) ── */}
|
||||
<div className="device-sync-device-panel">
|
||||
<div className="device-sync-panel-header">
|
||||
<span className="device-sync-panel-title">
|
||||
{t('deviceSync.onDevice')}
|
||||
{scanning && <Loader2 size={12} className="spin" style={{ marginLeft: 6 }} />}
|
||||
</span>
|
||||
<div className="device-sync-panel-actions">
|
||||
{/* Sync button */}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={promptSyncSummary}
|
||||
disabled={actionButtonDisabled}
|
||||
>
|
||||
{isRunning
|
||||
? <><Loader2 size={13} className="spin" /> {jobDone + jobSkip + jobFail}/{jobTotal}</>
|
||||
: <>
|
||||
{deletionCount > 0 && pendingCount === 0
|
||||
? <Trash2 size={13} />
|
||||
: <HardDriveUpload size={13} />}
|
||||
{actionButtonLabel}
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Mark for deletion */}
|
||||
{checkedIds.length > 0 && !isRunning && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleMarkCheckedForDeletion}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status summary badges */}
|
||||
{sources.length > 0 && (
|
||||
<div className="device-sync-status-summary">
|
||||
{syncedCount > 0 && (
|
||||
<span className="device-sync-badge synced">
|
||||
<CheckCircle2 size={11} /> {syncedCount} {t('deviceSync.statusSynced')}
|
||||
</span>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<span className="device-sync-badge pending">
|
||||
<Clock size={11} /> {pendingCount} {t('deviceSync.statusPending')}
|
||||
</span>
|
||||
)}
|
||||
{deletionCount > 0 && (
|
||||
<span className="device-sync-badge deletion">
|
||||
<Trash2 size={11} /> {deletionCount} {t('deviceSync.statusDeletion')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<p className="device-sync-empty">{t('deviceSync.noSourcesSelected')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="device-sync-list-header">
|
||||
<label className="device-sync-check-label">
|
||||
<input type="checkbox" checked={allChecked} onChange={toggleAll} />
|
||||
</label>
|
||||
<span className="device-sync-list-col-name">{t('deviceSync.colName')}</span>
|
||||
<span className="device-sync-list-col-type">{t('deviceSync.colType')}</span>
|
||||
<span className="device-sync-list-col-status">{t('deviceSync.colStatus')}</span>
|
||||
<span className="device-sync-list-col-actions" />
|
||||
</div>
|
||||
<div className="device-sync-device-list">
|
||||
{sources.map(s => {
|
||||
const status = sourceStatuses.get(s.id) ?? 'pending';
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={`device-sync-device-row ${status}${checkedIds.includes(s.id) ? ' checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedIds.includes(s.id)}
|
||||
onChange={() => toggleChecked(s.id)}
|
||||
disabled={status === 'deletion'}
|
||||
/>
|
||||
<span className="device-sync-row-name">{s.name}</span>
|
||||
<span className="device-sync-source-type">{s.type}</span>
|
||||
<span className={`device-sync-status-icon ${status}`}>
|
||||
{status === 'synced' && <CheckCircle2 size={13} />}
|
||||
{status === 'pending' && <Clock size={13} />}
|
||||
{status === 'deletion' && <Trash2 size={13} />}
|
||||
</span>
|
||||
<span className="device-sync-row-actions">
|
||||
{status === 'synced' && (
|
||||
<button
|
||||
className="device-sync-action-btn danger"
|
||||
onClick={e => { e.preventDefault(); markForDeletion([s.id]); }}
|
||||
data-tooltip={t('deviceSync.markForDeletion')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'pending' && (
|
||||
<button
|
||||
className="device-sync-action-btn muted"
|
||||
onClick={e => { e.preventDefault(); handleToggleSource(s); }}
|
||||
data-tooltip={t('deviceSync.removeSource')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'deletion' && (
|
||||
<button
|
||||
className="device-sync-action-btn undo"
|
||||
onClick={e => { e.preventDefault(); unmarkDeletion(s.id); }}
|
||||
data-tooltip={t('deviceSync.undoDeletion')}
|
||||
>
|
||||
<Undo2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Background sync progress (non-blocking) */}
|
||||
{jobStatus === 'running' && (
|
||||
<div className="device-sync-bg-progress">
|
||||
<div className="device-sync-bg-progress-bar-wrap">
|
||||
<div
|
||||
className="device-sync-bg-progress-bar"
|
||||
style={{ width: jobTotal > 0
|
||||
? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%`
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="device-sync-bg-progress-text">
|
||||
<Loader2 size={12} className="spin" />
|
||||
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
|
||||
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobStatus === 'done' && (
|
||||
<div className="device-sync-bg-progress done">
|
||||
<span className="device-sync-bg-progress-text">
|
||||
<CheckCircle2 size={12} className="color-success" />
|
||||
{t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
||||
{t('deviceSync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Pre-Sync Summary Modal */}
|
||||
{preSyncOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content device-sync-modal">
|
||||
<h2 className="modal-title">{t('deviceSync.syncSummary')}</h2>
|
||||
|
||||
{preSyncLoading ? (
|
||||
<div className="device-sync-loading-modal" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', margin: '20px' }}>
|
||||
<Loader2 size={32} className="spin" />
|
||||
<p style={{ marginTop: '10px' }}>{t('deviceSync.calculating')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="device-sync-summary-stats" style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '10px 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<span>{t('deviceSync.filesToAdd')}</span>
|
||||
<span className="color-success">+{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB)</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<span>{t('deviceSync.filesToDelete')}</span>
|
||||
<span className="color-error">-{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB)</span>
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '10px 0' }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<span>{t('deviceSync.netChange')}</span>
|
||||
<span>{((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', color: syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
|
||||
<span>{t('deviceSync.availableSpace')}</span>
|
||||
<span>{(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB</span>
|
||||
</div>
|
||||
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
|
||||
<div className="sync-warning error" style={{ background: 'color-mix(in srgb, var(--danger) 15%, transparent)', padding: '10px', borderRadius: 'var(--radius-md)', marginTop: '15px', display: 'flex', gap: '10px', color: 'var(--danger)', alignItems: 'flex-start' }}>
|
||||
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: '2px' }} />
|
||||
<span>{t('deviceSync.spaceWarning')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!preSyncLoading && (
|
||||
<div className="modal-actions" style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '25px' }}>
|
||||
<button className="btn btn-ghost" onClick={() => setPreSyncOpen(false)}>
|
||||
{t('deviceSync.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSyncExecution}
|
||||
disabled={syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes}
|
||||
>
|
||||
{t('deviceSync.proceed')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BrowserRow ──────────────────────────────────────────────────────────────
|
||||
|
||||
function BrowserRow({ name, meta, selected, onToggle, indent }: {
|
||||
name: string; meta?: string; selected: boolean; onToggle: () => void; indent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button className={`device-sync-browser-row${selected ? ' selected' : ''}${indent ? ' indent' : ''}`} onClick={onToggle}>
|
||||
<span className="device-sync-row-check">
|
||||
{selected ? <CheckCircle2 size={14} /> : <span className="device-sync-row-circle" />}
|
||||
</span>
|
||||
<span className="device-sync-row-name">{name}</span>
|
||||
{meta && <span className="device-sync-row-meta">{meta}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
@@ -45,10 +46,10 @@ function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
return formatHumanHoursMinutes(total);
|
||||
}
|
||||
|
||||
function codecLabel(song: SubsonicSong): string {
|
||||
function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -107,6 +108,10 @@ export default function PlaylistDetail() {
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
|
||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -540,10 +545,12 @@ export default function PlaylistDetail() {
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<div className="album-detail-header">
|
||||
{resolvedBgUrl && (
|
||||
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
|
||||
{resolvedBgUrl && enableCoverArtBackground && (
|
||||
<>
|
||||
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
|
||||
@@ -552,31 +559,33 @@ export default function PlaylistDetail() {
|
||||
|
||||
<div className="album-detail-hero">
|
||||
{/* Cover — click to open edit modal */}
|
||||
<div
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
>
|
||||
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
alt=""
|
||||
className="playlist-cover-grid"
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
{enablePlaylistCoverPhoto && (
|
||||
<div
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
>
|
||||
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
alt=""
|
||||
className="playlist-cover-grid"
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="playlist-hero-cover-overlay">
|
||||
<Camera size={28} />
|
||||
</div>
|
||||
)}
|
||||
<div className="playlist-hero-cover-overlay">
|
||||
<Camera size={28} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
|
||||
@@ -1059,7 +1068,7 @@ export default function PlaylistDetail() {
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
@@ -1148,7 +1157,7 @@ export default function PlaylistDetail() {
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import ArtistRow from '../components/ArtistRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
function formatDuration(s: number) {
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
|
||||
@@ -23,6 +24,7 @@ export default function SearchResults() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const psyDrag = useDragDrop();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setResults(null); return; }
|
||||
@@ -116,7 +118,7 @@ export default function SearchResults() {
|
||||
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
|
||||
<span className="track-codec" style={{ alignSelf: 'center' }}>
|
||||
{[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
{[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
|
||||
+57
-2
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock
|
||||
} from 'lucide-react';
|
||||
import i18n from '../i18n';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
@@ -114,6 +114,7 @@ const CONTRIBUTORS = [
|
||||
'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)',
|
||||
'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)',
|
||||
'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)',
|
||||
'Opus audio playback via symphonia-adapter-libopus with bundled libopus (PR #183)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -138,6 +139,8 @@ const CONTRIBUTORS = [
|
||||
'Spanish (es) translation — 964 strings (PR #159)',
|
||||
'Column-header sorting for albums & playlists (PR #160)',
|
||||
'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)',
|
||||
'3 visual toggles: cover art background, playlist cover photo, show bitrate badge (PR #181)',
|
||||
'8 community themes (AMOLED Black, Monochrome Dark, Amber Night, Phosphor Green, Midnight Blue, Rose Dark, Sepia Dark, Ice Blue) + waveform live theme update (PR #182)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1612,6 +1615,47 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.visualOptionsTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<ZoomIn size={18} />
|
||||
@@ -1982,7 +2026,18 @@ export default function Settings() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
{srv.url.startsWith('https://') && (
|
||||
<Lock size={11} style={{ color: 'var(--positive)', flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{srv.url.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||
<User size={11} />
|
||||
{srv.username}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
|
||||
@@ -546,7 +546,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
const s = get();
|
||||
const server = s.servers.find(srv => srv.id === s.activeServerId);
|
||||
if (!server?.url) return '';
|
||||
return server.url.startsWith('http') ? server.url : `http://${server.url}`;
|
||||
const base = server.url.startsWith('http') ? server.url : `http://${server.url}`;
|
||||
return base.replace(/\/$/, '');
|
||||
},
|
||||
|
||||
getActiveServer: () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface DeviceSyncJobState {
|
||||
jobId: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'running' | 'done' | 'cancelled';
|
||||
|
||||
startSync: (jobId: string, total: number) => void;
|
||||
updateProgress: (done: number, skipped: number, failed: number) => void;
|
||||
complete: (done: number, skipped: number, failed: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
|
||||
jobId: null,
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
status: 'idle',
|
||||
|
||||
startSync: (jobId, total) =>
|
||||
set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }),
|
||||
|
||||
updateProgress: (done, skipped, failed) =>
|
||||
set({ done, skipped, failed }),
|
||||
|
||||
complete: (done, skipped, failed) =>
|
||||
set({ done, skipped, failed, status: 'done' }),
|
||||
|
||||
reset: () =>
|
||||
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
|
||||
}));
|
||||
@@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface DeviceSyncSource {
|
||||
type: 'album' | 'playlist' | 'artist';
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DeviceSyncState {
|
||||
targetDir: string | null;
|
||||
filenameTemplate: string;
|
||||
sources: DeviceSyncSource[]; // persistent device content list
|
||||
checkedIds: string[]; // currently checked for bulk actions (not persisted)
|
||||
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
|
||||
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
|
||||
scanning: boolean; // true while scanning the device
|
||||
|
||||
setTargetDir: (dir: string | null) => void;
|
||||
setFilenameTemplate: (t: string) => void;
|
||||
addSource: (source: DeviceSyncSource) => void;
|
||||
removeSource: (id: string) => void;
|
||||
clearSources: () => void;
|
||||
toggleChecked: (id: string) => void;
|
||||
setCheckedIds: (ids: string[]) => void;
|
||||
markForDeletion: (ids: string[]) => void;
|
||||
unmarkDeletion: (id: string) => void;
|
||||
clearPendingDeletion: () => void;
|
||||
removeSources: (ids: string[]) => void;
|
||||
setDeviceFilePaths: (paths: string[]) => void;
|
||||
setScanning: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
targetDir: null,
|
||||
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
|
||||
sources: [],
|
||||
checkedIds: [],
|
||||
pendingDeletion: [],
|
||||
deviceFilePaths: [],
|
||||
scanning: false,
|
||||
|
||||
setTargetDir: (dir) => set({ targetDir: dir }),
|
||||
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
|
||||
|
||||
addSource: (source) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.some((x) => x.id === source.id)
|
||||
? s.sources
|
||||
: [...s.sources, source],
|
||||
})),
|
||||
|
||||
removeSource: (id) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => x.id !== id),
|
||||
checkedIds: s.checkedIds.filter((x) => x !== id),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
|
||||
|
||||
toggleChecked: (id) =>
|
||||
set((s) => ({
|
||||
checkedIds: s.checkedIds.includes(id)
|
||||
? s.checkedIds.filter((x) => x !== id)
|
||||
: [...s.checkedIds, id],
|
||||
})),
|
||||
|
||||
setCheckedIds: (ids) => set({ checkedIds: ids }),
|
||||
|
||||
markForDeletion: (ids) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])],
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
unmarkDeletion: (id) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearPendingDeletion: () => set({ pendingDeletion: [] }),
|
||||
|
||||
removeSources: (ids) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => !ids.includes(x.id)),
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }),
|
||||
setScanning: (v) => set({ scanning: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_device_sync',
|
||||
partialize: (s) => ({
|
||||
targetDir: s.targetDir,
|
||||
filenameTemplate: s.filenameTemplate,
|
||||
sources: s.sources,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
{ id: 'radio', visible: true },
|
||||
{ id: 'folderBrowser', visible: false },
|
||||
{ id: 'deviceSync', visible: false },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
|
||||
@@ -16,6 +16,12 @@ interface ThemeState {
|
||||
setTimeDayStart: (v: string) => void;
|
||||
timeNightStart: string;
|
||||
setTimeNightStart: (v: string) => void;
|
||||
enableCoverArtBackground: boolean;
|
||||
setEnableCoverArtBackground: (v: boolean) => void;
|
||||
enablePlaylistCoverPhoto: boolean;
|
||||
setEnablePlaylistCoverPhoto: (v: boolean) => void;
|
||||
showBitrate: boolean;
|
||||
setShowBitrate: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
||||
@@ -47,6 +53,12 @@ export const useThemeStore = create<ThemeState>()(
|
||||
setTimeDayStart: (v) => set({ timeDayStart: v }),
|
||||
timeNightStart: '19:00',
|
||||
setTimeNightStart: (v) => set({ timeNightStart: v }),
|
||||
enableCoverArtBackground: true,
|
||||
setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }),
|
||||
enablePlaylistCoverPhoto: true,
|
||||
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
|
||||
showBitrate: true,
|
||||
setShowBitrate: (v) => set({ showBitrate: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
|
||||
@@ -7558,3 +7558,642 @@ html.no-compositing .fs-seekbar-played {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
/* ─── Device Sync ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.device-sync-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 24px 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
|
||||
.device-sync-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-header-title h1 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Config Row (Template + Drive Layout) ── */
|
||||
|
||||
.device-sync-config-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-sync-template-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.device-sync-target-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end; /* Flush right alignment */
|
||||
flex: 1; /* allow block expansion */
|
||||
}
|
||||
|
||||
.device-sync-folder-path {
|
||||
max-width: 260px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 5px 8px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Drive layout ── */
|
||||
|
||||
.device-sync-drive-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-sync-drive-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-sync-drive-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.device-sync-drive-icon {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-drive-select {
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
font-size: 0.95rem;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-sync-drive-controls .btn-ghost {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-no-drives {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--warning, #f59e0b);
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.device-sync-label-inline {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-sync-template-input-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-sync-template-input {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.device-sync-template-preview {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-left: 2px;
|
||||
opacity: 0.75;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Main area (device panel + browser) ── */
|
||||
|
||||
.device-sync-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Device panel ── */
|
||||
|
||||
.device-sync-device-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
height: 52px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-panel-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.device-sync-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.device-sync-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 20px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Device list ── */
|
||||
|
||||
.device-sync-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-sync-list-col-name {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.device-sync-list-col-type {
|
||||
width: 70px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-device-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-sync-device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-device-row:last-child { border-bottom: none; }
|
||||
.device-sync-source-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.device-sync-source-row.deletion, .device-sync-row.deletion {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.device-sync-row-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-device-row.checked { background: var(--accent-dim); }
|
||||
|
||||
.device-sync-source-type {
|
||||
width: 70px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Status summary badges ── */
|
||||
|
||||
.device-sync-status-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.device-sync-badge.synced {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
}
|
||||
|
||||
.device-sync-badge.pending {
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent);
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.device-sync-badge.deletion {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* ── Status column ── */
|
||||
|
||||
.device-sync-list-col-status {
|
||||
width: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-sync-list-col-actions {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Status icons per row ── */
|
||||
|
||||
.device-sync-status-icon {
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-status-icon.synced { color: var(--success, #4ade80); }
|
||||
.device-sync-status-icon.pending { color: var(--warning, #f59e0b); }
|
||||
.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); }
|
||||
|
||||
/* ── Per-row action buttons ── */
|
||||
|
||||
.device-sync-row-actions {
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.device-sync-device-row:hover .device-sync-action-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.device-sync-action-btn.danger {
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
.device-sync-action-btn.danger:hover {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-sync-action-btn.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.device-sync-action-btn.muted:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-sync-action-btn.undo {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.device-sync-action-btn.undo:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ── Row status variants ── */
|
||||
|
||||
.device-sync-device-row.synced {}
|
||||
.device-sync-device-row.pending {}
|
||||
.device-sync-device-row.deletion {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.device-sync-device-row.deletion .device-sync-row-name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* ── Background sync progress (non-blocking) ── */
|
||||
|
||||
.device-sync-bg-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress.done {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
|
||||
.color-success { color: var(--success, #4ade80); }
|
||||
|
||||
/* ── Browser panel ── */
|
||||
|
||||
.device-sync-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-tabs {
|
||||
display: flex;
|
||||
height: 52px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.device-sync-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.device-sync-tab.active { color: var(--accent); border-bottom: 2px solid var(--accent); }
|
||||
|
||||
.device-sync-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-search-wrap .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-sync-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.device-sync-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-sync-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-sync-browser-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-browser-row:hover { background: var(--bg-hover); }
|
||||
.device-sync-browser-row.selected { background: var(--accent-dim); }
|
||||
.device-sync-browser-row.indent { padding-left: 36px; background: var(--bg-secondary); }
|
||||
.device-sync-browser-row.indent:hover { background: var(--bg-hover); }
|
||||
.device-sync-browser-row.indent.selected { background: var(--accent-dim); }
|
||||
|
||||
.device-sync-artist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.device-sync-expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.device-sync-expand-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.device-sync-row-check { color: var(--accent); display: flex; flex-shrink: 0; }
|
||||
.device-sync-row-circle {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-sync-row-name { flex: 1; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }
|
||||
|
||||
@@ -912,6 +912,13 @@
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Sidebar device-sync queue ─── */
|
||||
.sidebar-sync-queue {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 10%, var(--bg-sidebar));
|
||||
border-color: color-mix(in srgb, var(--success, #4ade80) 25%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -14423,3 +14423,541 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--danger: #d55e00;
|
||||
--volume-accent: #ffd700;
|
||||
}
|
||||
|
||||
/* ─── COMMUNITY THEMES ─── */
|
||||
|
||||
/* ─── AMOLED Black Pure ─── */
|
||||
[data-theme='amoled-black-pure'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds — absolute black everywhere */
|
||||
--ctp-crust: #000000;
|
||||
--ctp-mantle: #000000;
|
||||
--ctp-base: #000000;
|
||||
--ctp-surface0: #000000;
|
||||
--ctp-surface1: #050505;
|
||||
--ctp-surface2: #0a0a0a;
|
||||
|
||||
/* Overlays / muted text — raised for contrast */
|
||||
--ctp-overlay0: #555555;
|
||||
--ctp-overlay1: #707070;
|
||||
--ctp-overlay2: #8a8a8a;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #ffffff;
|
||||
--ctp-subtext1: #e0e0e0;
|
||||
--ctp-subtext0: #bbbbbb;
|
||||
|
||||
/* Accent — pure white */
|
||||
--ctp-teal: #ffffff;
|
||||
--ctp-sky: #ffffff;
|
||||
--ctp-sapphire: #e0e0e0;
|
||||
--ctp-blue: #cccccc;
|
||||
--ctp-lavender: #ffffff;
|
||||
--ctp-mauve: #e0e0e0;
|
||||
--ctp-pink: #cccccc;
|
||||
--ctp-flamingo: #ffffff;
|
||||
--ctp-rosewater: #e0e0e0;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #f87171;
|
||||
--ctp-maroon: #fb923c;
|
||||
--ctp-peach: #fb923c;
|
||||
--ctp-yellow: #fbbf24;
|
||||
--ctp-green: #4ade80;
|
||||
|
||||
/* UI tokens — all black */
|
||||
--bg-app: #000000;
|
||||
--bg-sidebar: #000000;
|
||||
--bg-card: #000000;
|
||||
--bg-hover: #0a0a0a;
|
||||
--bg-player: #000000;
|
||||
--bg-glass: rgba(0, 0, 0, 0.95);
|
||||
|
||||
--accent: #ffffff;
|
||||
--accent-dim: rgba(255, 255, 255, 0.07);
|
||||
--accent-glow: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-muted: #777777;
|
||||
|
||||
--border: #141414;
|
||||
--border-subtle: #0a0a0a;
|
||||
--border-dropdown: #1f1f1f;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.98);
|
||||
|
||||
--positive: #4ade80;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f87171;
|
||||
}
|
||||
|
||||
/* ─── Monochrome Dark ─── */
|
||||
[data-theme='monochrome'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c0c0c0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds — deep grayscale */
|
||||
--ctp-crust: #0d0d0d;
|
||||
--ctp-mantle: #111111;
|
||||
--ctp-base: #161616;
|
||||
--ctp-surface0: #1c1c1c;
|
||||
--ctp-surface1: #232323;
|
||||
--ctp-surface2: #2a2a2a;
|
||||
|
||||
/* Overlays — medium grays with good contrast */
|
||||
--ctp-overlay0: #555555;
|
||||
--ctp-overlay1: #6e6e6e;
|
||||
--ctp-overlay2: #888888;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #e8e8e8;
|
||||
--ctp-subtext1: #c0c0c0;
|
||||
--ctp-subtext0: #999999;
|
||||
|
||||
/* Accent — silver / light gray */
|
||||
--ctp-teal: #c0c0c0;
|
||||
--ctp-sky: #c0c0c0;
|
||||
--ctp-sapphire: #aaaaaa;
|
||||
--ctp-blue: #999999;
|
||||
--ctp-lavender: #c0c0c0;
|
||||
--ctp-mauve: #aaaaaa;
|
||||
--ctp-pink: #999999;
|
||||
--ctp-flamingo: #c0c0c0;
|
||||
--ctp-rosewater: #aaaaaa;
|
||||
|
||||
/* Semantic — desaturated for monochrome */
|
||||
--ctp-red: #cc6666;
|
||||
--ctp-maroon: #bb7744;
|
||||
--ctp-peach: #bb7744;
|
||||
--ctp-yellow: #bbaa55;
|
||||
--ctp-green: #77aa77;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #161616;
|
||||
--bg-sidebar: #111111;
|
||||
--bg-card: #1c1c1c;
|
||||
--bg-hover: #232323;
|
||||
--bg-player: #111111;
|
||||
--bg-glass: rgba(22, 22, 22, 0.95);
|
||||
|
||||
--accent: #c0c0c0;
|
||||
--accent-dim: rgba(192, 192, 192, 0.07);
|
||||
--accent-glow: rgba(192, 192, 192, 0.12);
|
||||
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-muted: #777777;
|
||||
|
||||
--border: #282828;
|
||||
--border-subtle: #1f1f1f;
|
||||
--border-dropdown: #303030;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.85);
|
||||
|
||||
--positive: #77aa77;
|
||||
--warning: #bbaa55;
|
||||
--danger: #cc6666;
|
||||
}
|
||||
|
||||
/* ─── Amber Night ─── */
|
||||
[data-theme='amber-night'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23d4a96a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #0a0804;
|
||||
--ctp-mantle: #0f0c07;
|
||||
--ctp-base: #1a1410;
|
||||
--ctp-surface0: #201a12;
|
||||
--ctp-surface1: #2a2318;
|
||||
--ctp-surface2: #332b1e;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #5a4a30;
|
||||
--ctp-overlay1: #7a6540;
|
||||
--ctp-overlay2: #9a8055;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #f0e0c8;
|
||||
--ctp-subtext1: #d4b88a;
|
||||
--ctp-subtext0: #b09060;
|
||||
|
||||
/* Golden amber accent */
|
||||
--ctp-teal: #d4a96a;
|
||||
--ctp-sky: #d4a96a;
|
||||
--ctp-sapphire: #c49055;
|
||||
--ctp-blue: #b07840;
|
||||
--ctp-lavender: #d4a96a;
|
||||
--ctp-mauve: #c49055;
|
||||
--ctp-pink: #b07840;
|
||||
--ctp-flamingo: #d4a96a;
|
||||
--ctp-rosewater: #c49055;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e07060;
|
||||
--ctp-maroon: #d08050;
|
||||
--ctp-peach: #d08050;
|
||||
--ctp-yellow: #d4a96a;
|
||||
--ctp-green: #80b870;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #1a1410;
|
||||
--bg-sidebar: #0f0c07;
|
||||
--bg-card: #201a12;
|
||||
--bg-hover: #2a2318;
|
||||
--bg-player: #0f0c07;
|
||||
--bg-glass: rgba(26, 20, 16, 0.95);
|
||||
|
||||
--accent: #d4a96a;
|
||||
--accent-dim: rgba(212, 169, 106, 0.08);
|
||||
--accent-glow: rgba(212, 169, 106, 0.15);
|
||||
|
||||
--text-primary: #f0e0c8;
|
||||
--text-secondary: #b09060;
|
||||
--text-muted: #7a6040;
|
||||
|
||||
--border: #2a2318;
|
||||
--border-subtle: #201a12;
|
||||
--border-dropdown: #332b1e;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #80b870;
|
||||
--warning: #d4a96a;
|
||||
--danger: #e07060;
|
||||
}
|
||||
|
||||
/* ─── Phosphor Green ─── */
|
||||
[data-theme='phosphor-green'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%234ade80%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #050e05;
|
||||
--ctp-mantle: #070f07;
|
||||
--ctp-base: #0d1a0d;
|
||||
--ctp-surface0: #111f11;
|
||||
--ctp-surface1: #162616;
|
||||
--ctp-surface2: #1c2e1c;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #2d5a2d;
|
||||
--ctp-overlay1: #3d7a3d;
|
||||
--ctp-overlay2: #4d9a4d;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #d0f0d0;
|
||||
--ctp-subtext1: #a0d0a0;
|
||||
--ctp-subtext0: #70aa70;
|
||||
|
||||
/* Phosphor green accent */
|
||||
--ctp-teal: #4ade80;
|
||||
--ctp-sky: #4ade80;
|
||||
--ctp-sapphire: #38c070;
|
||||
--ctp-blue: #2aa060;
|
||||
--ctp-lavender: #4ade80;
|
||||
--ctp-mauve: #38c070;
|
||||
--ctp-pink: #2aa060;
|
||||
--ctp-flamingo: #4ade80;
|
||||
--ctp-rosewater: #38c070;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e06060;
|
||||
--ctp-maroon: #c07840;
|
||||
--ctp-peach: #c07840;
|
||||
--ctp-yellow: #b0b050;
|
||||
--ctp-green: #4ade80;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #0d1a0d;
|
||||
--bg-sidebar: #070f07;
|
||||
--bg-card: #111f11;
|
||||
--bg-hover: #162616;
|
||||
--bg-player: #070f07;
|
||||
--bg-glass: rgba(13, 26, 13, 0.95);
|
||||
|
||||
--accent: #4ade80;
|
||||
--accent-dim: rgba(74, 222, 128, 0.07);
|
||||
--accent-glow: rgba(74, 222, 128, 0.14);
|
||||
|
||||
--text-primary: #d0f0d0;
|
||||
--text-secondary: #70aa70;
|
||||
--text-muted: #3d6a3d;
|
||||
|
||||
--border: #1c2e1c;
|
||||
--border-subtle: #111f11;
|
||||
--border-dropdown: #223022;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.88);
|
||||
|
||||
--positive: #4ade80;
|
||||
--warning: #b0b050;
|
||||
--danger: #e06060;
|
||||
}
|
||||
|
||||
/* ─── Midnight Blue ─── */
|
||||
[data-theme='midnight-blue'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2360a5fa%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #07090f;
|
||||
--ctp-mantle: #090c14;
|
||||
--ctp-base: #0d1420;
|
||||
--ctp-surface0: #111a28;
|
||||
--ctp-surface1: #162030;
|
||||
--ctp-surface2: #1c2838;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #2a3d5a;
|
||||
--ctp-overlay1: #3a507a;
|
||||
--ctp-overlay2: #4a659a;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #ccd8f0;
|
||||
--ctp-subtext1: #9ab0d8;
|
||||
--ctp-subtext0: #6888b0;
|
||||
|
||||
/* Light blue accent */
|
||||
--ctp-teal: #60a5fa;
|
||||
--ctp-sky: #60a5fa;
|
||||
--ctp-sapphire: #4a90e8;
|
||||
--ctp-blue: #3878d0;
|
||||
--ctp-lavender: #60a5fa;
|
||||
--ctp-mauve: #4a90e8;
|
||||
--ctp-pink: #3878d0;
|
||||
--ctp-flamingo: #60a5fa;
|
||||
--ctp-rosewater: #4a90e8;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e07070;
|
||||
--ctp-maroon: #d09050;
|
||||
--ctp-peach: #d09050;
|
||||
--ctp-yellow: #c8b850;
|
||||
--ctp-green: #60c880;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #0d1420;
|
||||
--bg-sidebar: #090c14;
|
||||
--bg-card: #111a28;
|
||||
--bg-hover: #162030;
|
||||
--bg-player: #090c14;
|
||||
--bg-glass: rgba(13, 20, 32, 0.95);
|
||||
|
||||
--accent: #60a5fa;
|
||||
--accent-dim: rgba(96, 165, 250, 0.07);
|
||||
--accent-glow: rgba(96, 165, 250, 0.14);
|
||||
|
||||
--text-primary: #ccd8f0;
|
||||
--text-secondary: #6888b0;
|
||||
--text-muted: #3a5070;
|
||||
|
||||
--border: #1c2838;
|
||||
--border-subtle: #111a28;
|
||||
--border-dropdown: #223040;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #60c880;
|
||||
--warning: #c8b850;
|
||||
--danger: #e07070;
|
||||
}
|
||||
|
||||
/* ─── Rose Dark ─── */
|
||||
[data-theme='rose-dark'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23f472b6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #0e080b;
|
||||
--ctp-mantle: #120a0e;
|
||||
--ctp-base: #1a0d14;
|
||||
--ctp-surface0: #20111a;
|
||||
--ctp-surface1: #281520;
|
||||
--ctp-surface2: #301a27;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #5a2a40;
|
||||
--ctp-overlay1: #7a3a58;
|
||||
--ctp-overlay2: #9a5070;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #f0d0e0;
|
||||
--ctp-subtext1: #d8a0c0;
|
||||
--ctp-subtext0: #b07090;
|
||||
|
||||
/* Pink accent */
|
||||
--ctp-teal: #f472b6;
|
||||
--ctp-sky: #f472b6;
|
||||
--ctp-sapphire: #e055a0;
|
||||
--ctp-blue: #c8408a;
|
||||
--ctp-lavender: #f472b6;
|
||||
--ctp-mauve: #e055a0;
|
||||
--ctp-pink: #c8408a;
|
||||
--ctp-flamingo: #f472b6;
|
||||
--ctp-rosewater: #e055a0;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e06070;
|
||||
--ctp-maroon: #d07850;
|
||||
--ctp-peach: #d07850;
|
||||
--ctp-yellow: #c8a850;
|
||||
--ctp-green: #70b880;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #1a0d14;
|
||||
--bg-sidebar: #120a0e;
|
||||
--bg-card: #20111a;
|
||||
--bg-hover: #281520;
|
||||
--bg-player: #120a0e;
|
||||
--bg-glass: rgba(26, 13, 20, 0.95);
|
||||
|
||||
--accent: #f472b6;
|
||||
--accent-dim: rgba(244, 114, 182, 0.07);
|
||||
--accent-glow: rgba(244, 114, 182, 0.14);
|
||||
|
||||
--text-primary: #f0d0e0;
|
||||
--text-secondary: #b07090;
|
||||
--text-muted: #704858;
|
||||
|
||||
--border: #281520;
|
||||
--border-subtle: #20111a;
|
||||
--border-dropdown: #341a28;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #70b880;
|
||||
--warning: #c8a850;
|
||||
--danger: #e06070;
|
||||
}
|
||||
|
||||
/* ─── Sepia Dark ─── */
|
||||
[data-theme='sepia-dark'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8b89a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #0e0c09;
|
||||
--ctp-mantle: #13100c;
|
||||
--ctp-base: #1e1a14;
|
||||
--ctp-surface0: #252018;
|
||||
--ctp-surface1: #2e281e;
|
||||
--ctp-surface2: #373025;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #605545;
|
||||
--ctp-overlay1: #806e5a;
|
||||
--ctp-overlay2: #9a8870;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #ede0cc;
|
||||
--ctp-subtext1: #d0c0a8;
|
||||
--ctp-subtext0: #b0a080;
|
||||
|
||||
/* Cream accent */
|
||||
--ctp-teal: #c8b89a;
|
||||
--ctp-sky: #c8b89a;
|
||||
--ctp-sapphire: #b8a080;
|
||||
--ctp-blue: #a08868;
|
||||
--ctp-lavender: #c8b89a;
|
||||
--ctp-mauve: #b8a080;
|
||||
--ctp-pink: #a08868;
|
||||
--ctp-flamingo: #c8b89a;
|
||||
--ctp-rosewater: #b8a080;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #c87060;
|
||||
--ctp-maroon: #c08050;
|
||||
--ctp-peach: #c08050;
|
||||
--ctp-yellow: #b8a050;
|
||||
--ctp-green: #80a870;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #1e1a14;
|
||||
--bg-sidebar: #13100c;
|
||||
--bg-card: #252018;
|
||||
--bg-hover: #2e281e;
|
||||
--bg-player: #13100c;
|
||||
--bg-glass: rgba(30, 26, 20, 0.95);
|
||||
|
||||
--accent: #c8b89a;
|
||||
--accent-dim: rgba(200, 184, 154, 0.07);
|
||||
--accent-glow: rgba(200, 184, 154, 0.13);
|
||||
|
||||
--text-primary: #ede0cc;
|
||||
--text-secondary: #b0a080;
|
||||
--text-muted: #706050;
|
||||
|
||||
--border: #2e281e;
|
||||
--border-subtle: #252018;
|
||||
--border-dropdown: #3a3025;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.88);
|
||||
|
||||
--positive: #80a870;
|
||||
--warning: #b8a050;
|
||||
--danger: #c87060;
|
||||
}
|
||||
|
||||
/* ─── Ice Blue ─── */
|
||||
[data-theme='ice-blue'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%237dd3e8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #07101a;
|
||||
--ctp-mantle: #09141e;
|
||||
--ctp-base: #0e1d28;
|
||||
--ctp-surface0: #132430;
|
||||
--ctp-surface1: #192c3a;
|
||||
--ctp-surface2: #203444;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #2a4a5a;
|
||||
--ctp-overlay1: #3a6070;
|
||||
--ctp-overlay2: #4a788a;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #cce8f0;
|
||||
--ctp-subtext1: #9accd8;
|
||||
--ctp-subtext0: #68a8b8;
|
||||
|
||||
/* Icy cyan accent */
|
||||
--ctp-teal: #7dd3e8;
|
||||
--ctp-sky: #7dd3e8;
|
||||
--ctp-sapphire: #60bcd5;
|
||||
--ctp-blue: #48a8c0;
|
||||
--ctp-lavender: #7dd3e8;
|
||||
--ctp-mauve: #60bcd5;
|
||||
--ctp-pink: #48a8c0;
|
||||
--ctp-flamingo: #7dd3e8;
|
||||
--ctp-rosewater: #60bcd5;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e07878;
|
||||
--ctp-maroon: #d09858;
|
||||
--ctp-peach: #d09858;
|
||||
--ctp-yellow: #c8c060;
|
||||
--ctp-green: #60c898;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #0e1d28;
|
||||
--bg-sidebar: #09141e;
|
||||
--bg-card: #132430;
|
||||
--bg-hover: #192c3a;
|
||||
--bg-player: #09141e;
|
||||
--bg-glass: rgba(14, 29, 40, 0.95);
|
||||
|
||||
--accent: #7dd3e8;
|
||||
--accent-dim: rgba(125, 211, 232, 0.07);
|
||||
--accent-glow: rgba(125, 211, 232, 0.14);
|
||||
|
||||
--text-primary: #cce8f0;
|
||||
--text-secondary: #68a8b8;
|
||||
--text-muted: #3a6878;
|
||||
|
||||
--border: #192c3a;
|
||||
--border-subtle: #132430;
|
||||
--border-dropdown: #223844;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #60c898;
|
||||
--warning: #c8c060;
|
||||
--danger: #e07878;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user