mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-25 16:55:45 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901d260bf1 | |||
| ebef8aa061 | |||
| 9de56bf638 | |||
| 842e041a6e | |||
| 0de488335e | |||
| 2c95a3499c | |||
| de191e45d5 |
+1
-139
@@ -56,16 +56,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Themes — community Theme Store
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020)**
|
||||
|
||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
||||
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
||||
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
|
||||
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
|
||||
* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
|
||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||
|
||||
|
||||
@@ -81,24 +78,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Startup — themed loading splash before the app bundle loads
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)**
|
||||
|
||||
* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch.
|
||||
* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset.
|
||||
* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility.
|
||||
|
||||
|
||||
|
||||
### Servers — software and version on each server card
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1045](https://github.com/Psychotoxical/psysonic/pull/1045)**
|
||||
|
||||
* Each server card under **Settings → Servers** now shows the server software and version (e.g. `Navidrome 0.62.0`) under the server name. The value comes from the existing connection ping, so no extra request is made; it is hidden for servers that don't report it (plain Subsonic without OpenSubsonic).
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
@@ -128,59 +107,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### PsyLab — Performance Probe rename, Tuning tab, and log tools
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)**
|
||||
|
||||
* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab.
|
||||
* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection.
|
||||
* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable.
|
||||
|
||||
|
||||
|
||||
### PsyLab — Connections tab and Navidrome admin role
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* New **Connections** tab: session/endpoint status, active-server capability readout (OpenSubsonic, AudioMuse detection, provider/strategy, detection trust, resolved call route, and AudioMuse mode), and queue-playback server when it differs from the active profile.
|
||||
* Navidrome **admin vs standard user** badge via native login probe — useful when diagnosing plugin/settings visibility.
|
||||
|
||||
|
||||
|
||||
### Servers — capability framework with AudioMuse sonic routing (Navidrome ≥ 0.62)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* New declarative **server-capability framework** (`src/serverCapabilities/`): a catalog picks a feature strategy per server generation, runs only the needed probes, and routes API calls — replacing scattered version checks in the UI and call sites.
|
||||
* Navidrome **0.62+**: detect the AudioMuse-AI plugin from `getOpenSubsonicExtensions` when `sonicSimilarity` is advertised — the first reliable signal. Settings shows an **auto-managed status indicator** (no manual toggle); older Navidrome keeps the manual toggle and the legacy `getSimilarSongs` Instant Mix probe.
|
||||
* **Path routing**: Instant Mix and Lucky Mix prefer the OpenSubsonic `getSonicSimilarTracks` endpoint when the plugin is present, falling back to legacy `getSimilarSongs`.
|
||||
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Now Playing — cards no longer blank out on track change
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
|
||||
|
||||
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
|
||||
|
||||
|
||||
|
||||
### Now Playing — metadata reads from the local library index first
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
|
||||
|
||||
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
|
||||
|
||||
|
||||
|
||||
### Library DB — named slow-write ops for stall diagnosis
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
|
||||
|
||||
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
|
||||
|
||||
### Servers — complete border on the active server card
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
|
||||
@@ -198,14 +126,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — macOS stutter from background device checks
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
|
||||
|
||||
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
|
||||
|
||||
|
||||
|
||||
### Track preview — Symphonia 0.6 format hints and fast stream start
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
|
||||
@@ -224,64 +144,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Song rails — multi-artist credits link to each artist
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1023](https://github.com/Psychotoxical/psysonic/pull/1023)**
|
||||
|
||||
* **Random Picks**, **Discover Songs**, and other song cards now split OpenSubsonic `artists[]` into individually clickable names — the same behaviour as album track rows and the player bar, instead of one link for the whole joined credit string.
|
||||
* Album cards and the rest of the app share the same artist-ref helper, including when Subsonic returns a single ref object instead of a one-element array.
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — corner clock follows Clock format setting
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1025](https://github.com/Psychotoxical/psysonic/pull/1025)**
|
||||
|
||||
* The wall clock in the fullscreen player now honours **Settings → System → Clock format** (24-hour vs 12-hour), matching the queue ETA and sleep-timer preview instead of always showing AM/PM.
|
||||
|
||||
|
||||
|
||||
### All Albums — Only compilations filter returns results
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1026](https://github.com/Psychotoxical/psysonic/pull/1026)**
|
||||
|
||||
* The **Only compilations** toggle on **All Albums** no longer returns an empty list when compilations are tagged via **Various Artists** as album artist or when genre is combined with other browse filters — local index SQL, track-grouped browse, and client-side detection now agree on the same compilation signals.
|
||||
|
||||
|
||||
|
||||
### Artist page — Top Tracks play button
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1031](https://github.com/Psychotoxical/psysonic/pull/1031)**
|
||||
|
||||
* Play on **Top Tracks** rows no longer silently does nothing when the artist page has top songs but no albums loaded (e.g. lossless artist view); playback starts from the clicked track and continues into the catalog when albums are available.
|
||||
|
||||
|
||||
|
||||
### PsyLab — tab bar no longer collapses on the Logs tab
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* The PsyLab tab row keeps its height when the Logs flex layout fills the modal — tabs were previously squashed to a thin strip.
|
||||
|
||||
|
||||
|
||||
### Themes — consistent focus borders on inputs and dropdowns
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
|
||||
|
||||
* Text fields no longer draw a double border when focused — they now show a single clean ring across every theme. The colour-blind-safe themes keep their stronger high-contrast focus ring on every field, including the header search.
|
||||
* Dropdown and popover borders now follow the active theme in all themes (a couple of themes previously rendered them with an unthemed colour).
|
||||
|
||||
|
||||
|
||||
### Home — Most Played no longer jumps the page when loading more
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#1053](https://github.com/Psychotoxical/psysonic/pull/1053)**
|
||||
|
||||
* Clicking the arrow to load more albums in the **Most Played** rail sometimes snapped the page up to an earlier section. Loading more now keeps the viewport in place.
|
||||
|
||||
|
||||
|
||||
## [1.47.0]
|
||||
|
||||
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
|
||||
|
||||
-82
@@ -6,91 +6,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<script src="/startup-splash-preflight.js"></script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
--splash-muted: var(--startup-splash-muted, #a6adc8);
|
||||
--splash-accent: var(--startup-splash-accent, #cba6f7);
|
||||
--splash-track: var(--startup-splash-track, #313244);
|
||||
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
|
||||
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
opacity: 1;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
#app-startup-splash.app-startup-splash--hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--splash-muted);
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar {
|
||||
width: min(240px, 72vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--splash-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar-fill {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--splash-accent);
|
||||
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes app-startup-splash-indeterminate {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
|
||||
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--splash-logo-start)" />
|
||||
<stop offset="100%" stop-color="var(--splash-logo-end)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(220.53237,27.789086)">
|
||||
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="app-startup-splash__bar" aria-hidden="true">
|
||||
<div class="app-startup-splash__bar-fill"></div>
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Synchronous startup splash theme (before the Vite bundle loads).
|
||||
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
|
||||
*/
|
||||
(function startupSplashPreflight() {
|
||||
var THEME_KEY = 'psysonic_theme';
|
||||
var INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
var DEFAULT = {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
};
|
||||
var BUILTIN = {
|
||||
mocha: DEFAULT,
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
function readCssVar(css, name) {
|
||||
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
|
||||
var value = match && match[1] ? match[1].trim() : '';
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveScheduledTheme(state) {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
var now = new Date();
|
||||
var nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
var dayParts = state.timeDayStart.split(':').map(Number);
|
||||
var nightParts = state.timeNightStart.split(':').map(Number);
|
||||
var dayMins = dayParts[0] * 60 + dayParts[1];
|
||||
var nightMins = nightParts[0] * 60 + nightParts[1];
|
||||
var isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
function readThemeState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var s = parsed && parsed.state;
|
||||
if (!s) return null;
|
||||
return {
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme || 'mocha'),
|
||||
themeDay: String(s.themeDay || 'latte'),
|
||||
themeNight: String(s.themeNight || 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart || '07:00'),
|
||||
timeNightStart: String(s.timeNightStart || '19:00'),
|
||||
};
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
var themes = parsed && parsed.state && parsed.state.themes;
|
||||
return Array.isArray(themes) ? themes : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function paletteForTheme(themeId, installedThemes) {
|
||||
if (BUILTIN[themeId]) return BUILTIN[themeId];
|
||||
for (var i = 0; i < installedThemes.length; i += 1) {
|
||||
var theme = installedThemes[i];
|
||||
if (!theme || theme.id !== themeId || !theme.css) continue;
|
||||
var bg = readCssVar(theme.css, '--bg-app');
|
||||
var accent = readCssVar(theme.css, '--accent');
|
||||
if (!bg || !accent) break;
|
||||
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
|
||||
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|
||||
|| readCssVar(theme.css, '--accent-2')
|
||||
|| accent;
|
||||
return {
|
||||
bg: bg,
|
||||
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
|
||||
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
|
||||
accent: accent,
|
||||
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
|
||||
logoStart: logoStart,
|
||||
logoEnd: logoEnd,
|
||||
};
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
function applyPalette(themeId, palette) {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.style.setProperty('--startup-splash-bg', palette.bg);
|
||||
root.style.setProperty('--startup-splash-text', palette.text);
|
||||
root.style.setProperty('--startup-splash-muted', palette.muted);
|
||||
root.style.setProperty('--startup-splash-accent', palette.accent);
|
||||
root.style.setProperty('--startup-splash-track', palette.track);
|
||||
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
|
||||
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
|
||||
root.style.background = palette.bg;
|
||||
if (document.body) document.body.style.background = palette.bg;
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
|
||||
var palette = paletteForTheme(themeId, readInstalledThemes());
|
||||
applyPalette(themeId, palette);
|
||||
})();
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
var MAX_ATTEMPTS = 60;
|
||||
|
||||
function tryShowMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -224,18 +224,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// The full `output_devices()` + per-device `description()` scan is the
|
||||
// CoreAudio HAL call that contends with the audio render thread and
|
||||
// produces a brief dropout once per poll interval (issue #996: stutter
|
||||
// every ~3s, cadence tracking the poll exactly). It is only needed to
|
||||
// detect a *pinned* output device disappearing. With no pin — system
|
||||
// default, the common case — only the current default is needed, a
|
||||
// single cheap query, so the full enumeration is skipped entirely.
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let need_full_enum = pinned.is_some();
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
@@ -253,28 +244,26 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list (only when we actually enumerated for a pinned device)
|
||||
// almost always means a transient enumeration failure, not that every
|
||||
// output device vanished. Treating it as "pinned missing" caused false
|
||||
// audio:device-reset (UI jumped back to system default) when switching
|
||||
// to external USB / class-compliant interfaces.
|
||||
if need_full_enum && available.is_empty() {
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod cover_cache_layout;
|
||||
pub mod log_sanitize;
|
||||
pub mod media_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
//! Redact secrets and partially mask remote server hostnames before log lines
|
||||
//! are stored or exported (PsyLab / Settings log export).
|
||||
|
||||
const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
||||
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
|
||||
"access_token", "refresh_token", "auth",
|
||||
];
|
||||
|
||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||
"refresh_token", "authorization", "auth",
|
||||
];
|
||||
|
||||
/// Sanitize one runtime log line for display and export.
|
||||
pub fn sanitize_log_line(line: &str) -> String {
|
||||
let mut out = redact_bearer_tokens(line);
|
||||
out = redact_sensitive_key_values(&out);
|
||||
out = redact_urls_in_text(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Never panic on the logging hot path — fall back to the raw line if needed.
|
||||
pub fn sanitize_log_line_infallible(line: &str) -> String {
|
||||
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
|
||||
fn redact_bearer_tokens(line: &str) -> String {
|
||||
let marker = "Bearer ";
|
||||
let mut s = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = s[search_from..].find(marker) {
|
||||
let idx = search_from + rel;
|
||||
let start = idx + marker.len();
|
||||
let end = s[start..]
|
||||
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(s.len());
|
||||
if end > start {
|
||||
s.replace_range(start..end, "REDACTED");
|
||||
}
|
||||
search_from = start + "REDACTED".len();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn redact_sensitive_key_values(line: &str) -> String {
|
||||
let mut out = line.to_string();
|
||||
for key in SENSITIVE_KV_KEYS {
|
||||
for sep in [':', '='] {
|
||||
let needle = format!("{key}{sep}");
|
||||
let lower = out.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find(&needle) {
|
||||
let idx = search_from + rel;
|
||||
let val_start = idx + needle.len();
|
||||
let slice = &out[val_start..];
|
||||
let trimmed = slice.trim_start();
|
||||
let ws = slice.len().saturating_sub(trimmed.len());
|
||||
let val_start = val_start + ws;
|
||||
let end = trimmed
|
||||
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||
.unwrap_or(trimmed.len());
|
||||
if end > 0 {
|
||||
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||
}
|
||||
search_from = val_start + "REDACTED".len();
|
||||
if search_from >= out.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
|
||||
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
|
||||
return true;
|
||||
}
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
if let Some(next) = s[byte_off..].chars().nth(1) {
|
||||
return next.is_whitespace() || next == '"' || next == '\'';
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn redact_urls_in_text(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < line.len() {
|
||||
let slice = &line[cursor..];
|
||||
let rel = match (slice.find("http://"), slice.find("https://")) {
|
||||
(Some(h), Some(s)) => Some(h.min(s)),
|
||||
(Some(h), None) => Some(h),
|
||||
(None, Some(s)) => Some(s),
|
||||
(None, None) => None,
|
||||
};
|
||||
let Some(rel) = rel else {
|
||||
out.push_str(slice);
|
||||
break;
|
||||
};
|
||||
out.push_str(&slice[..rel]);
|
||||
let url_start = cursor + rel;
|
||||
let url_slice = &line[url_start..];
|
||||
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
|
||||
let mut url_end = scheme_len;
|
||||
for (off, ch) in url_slice[scheme_len..].char_indices() {
|
||||
let abs = scheme_len + off;
|
||||
if url_char_ends_url(ch, url_slice, abs) {
|
||||
break;
|
||||
}
|
||||
url_end = abs + ch.len_utf8();
|
||||
}
|
||||
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
|
||||
cursor = url_start + url_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
let (url, suffix) = split_trailing_punct(raw);
|
||||
let mut out = String::new();
|
||||
|
||||
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
out.push_str(&url[..scheme_end]);
|
||||
|
||||
let mut rest = &url[scheme_end..];
|
||||
if let Some(at) = rest.rfind('@') {
|
||||
// Drop userinfo entirely.
|
||||
out.push_str("***@");
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
|
||||
let (hostport, path) = split_host_path(rest);
|
||||
let (host, port) = split_host_port(&hostport);
|
||||
let masked_host = mask_hostname(&host);
|
||||
out.push_str(&masked_host);
|
||||
if let Some(p) = port {
|
||||
out.push(':');
|
||||
out.push_str(&p);
|
||||
}
|
||||
if let Some((path_only, query)) = path.split_once('?') {
|
||||
out.push_str(path_only);
|
||||
out.push('?');
|
||||
out.push_str(&redact_query_string(query));
|
||||
} else {
|
||||
out.push_str(&path);
|
||||
}
|
||||
|
||||
format!("{out}{suffix}")
|
||||
}
|
||||
|
||||
fn split_trailing_punct(raw: &str) -> (&str, &str) {
|
||||
let mut end = raw.len();
|
||||
while end > 0 {
|
||||
let ch = raw.as_bytes()[end - 1] as char;
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
(&raw[..end], &raw[end..])
|
||||
}
|
||||
|
||||
fn split_host_path(rest: &str) -> (String, String) {
|
||||
if rest.starts_with('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
let hostport = &rest[..=end];
|
||||
return (hostport.to_string(), rest[end + 1..].to_string());
|
||||
}
|
||||
}
|
||||
if let Some(slash) = rest.find('/') {
|
||||
(rest[..slash].to_string(), rest[slash..].to_string())
|
||||
} else {
|
||||
(rest.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_host_port(hostport: &str) -> (String, Option<String>) {
|
||||
if hostport.starts_with('[') {
|
||||
if let Some(end) = hostport.find("]:") {
|
||||
return (
|
||||
hostport[..=end].to_string(),
|
||||
Some(hostport[end + 2..].to_string()),
|
||||
);
|
||||
}
|
||||
return (hostport.to_string(), None);
|
||||
}
|
||||
if let Some((h, p)) = hostport.rsplit_once(':') {
|
||||
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
|
||||
return (h.to_string(), Some(p.to_string()));
|
||||
}
|
||||
}
|
||||
(hostport.to_string(), None)
|
||||
}
|
||||
|
||||
fn redact_query_string(query: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.map(|pair| {
|
||||
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if is_sensitive_query_key(k) {
|
||||
format!("{k}=REDACTED")
|
||||
} else {
|
||||
pair.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn is_sensitive_query_key(key: &str) -> bool {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
|
||||
}
|
||||
|
||||
fn is_lan_ipv4(ip: &str) -> bool {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let Ok(a) = parts[0].parse::<u8>() else { return false };
|
||||
let Ok(b) = parts[1].parse::<u8>() else { return false };
|
||||
a == 127
|
||||
|| a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
}
|
||||
|
||||
fn is_lan_ipv6(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
if h == "::1" {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fc") || h.starts_with("fd") {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = h.strip_prefix("::ffff:") {
|
||||
if rest.contains('.') {
|
||||
return is_lan_ipv4(rest);
|
||||
}
|
||||
if let Some((a, b)) = rest.split_once(':') {
|
||||
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
|
||||
let ip = format!(
|
||||
"{}.{}.{}.{}",
|
||||
(v1 >> 8) & 0xff,
|
||||
v1 & 0xff,
|
||||
(v2 >> 8) & 0xff,
|
||||
v2 & 0xff
|
||||
);
|
||||
return is_lan_ipv4(&ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_lan_host(host: &str) -> bool {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
let lower = stripped.to_ascii_lowercase();
|
||||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
if stripped.contains(':') {
|
||||
return is_lan_ipv6(stripped);
|
||||
}
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return is_lan_ipv4(stripped);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn mask_label_prefix(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
let c1 = chars.next();
|
||||
let c2 = chars.next();
|
||||
match (c1, c2) {
|
||||
(None, _) => "*".to_string(),
|
||||
(Some(a), None) => a.to_string(),
|
||||
(Some(a), Some(b)) => {
|
||||
let rest = label.chars().count().saturating_sub(2);
|
||||
let stars = rest.clamp(1, 4);
|
||||
format!("{a}{b}{}", "*".repeat(stars))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_public_ipv4(ip: &str) -> String {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return "***".to_string();
|
||||
}
|
||||
format!("{}.*.*.{}", parts[0], parts[3])
|
||||
}
|
||||
|
||||
fn mask_hostname(host: &str) -> String {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
if is_lan_host(stripped) {
|
||||
return host.to_string();
|
||||
}
|
||||
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return mask_public_ipv4(stripped);
|
||||
}
|
||||
|
||||
if stripped.contains(':') {
|
||||
return "[ipv6-redacted]".to_string();
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = stripped.split('.').collect();
|
||||
if parts.is_empty() {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let masked_first = mask_label_prefix(parts[0]);
|
||||
|
||||
if parts.len() == 1 {
|
||||
masked_first
|
||||
} else {
|
||||
format!("{}.{}", masked_first, parts[1..].join("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_subsonic_wire_auth_params() {
|
||||
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("t=REDACTED"));
|
||||
assert!(out.contains("s=REDACTED"));
|
||||
assert!(out.contains("p=REDACTED"));
|
||||
assert!(!out.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_remote_hostname_keeps_lan_ip() {
|
||||
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
|
||||
assert!(remote.contains("my****.example.com"));
|
||||
assert!(!remote.contains("my-server.example.com"));
|
||||
|
||||
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
|
||||
assert!(lan.contains("192.168.1.42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_bearer_and_password_kv() {
|
||||
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("Bearer REDACTED"));
|
||||
assert!(!out.contains("eyJhbGci"));
|
||||
assert!(out.contains("password=REDACTED"));
|
||||
assert!(!out.contains("sekrit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_url_userinfo() {
|
||||
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("***@10.0.0.5"));
|
||||
assert!(!out.contains("user:pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_log_with_em_dash_does_not_panic() {
|
||||
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains('—'));
|
||||
assert!(out.contains("RangedHttpSource"));
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,6 @@ pub fn should_log_debug() -> bool {
|
||||
}
|
||||
|
||||
pub fn append_log_line(line: String) {
|
||||
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
|
||||
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
{
|
||||
let mut buf = log_buffer().lock().unwrap();
|
||||
|
||||
@@ -460,8 +460,8 @@ fn build_album_from_tracks(
|
||||
);
|
||||
|
||||
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
|
||||
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at)";
|
||||
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
|
||||
MAX(t.starred_at), MAX(t.synced_at)";
|
||||
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
|
||||
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
|
||||
});
|
||||
@@ -868,10 +868,10 @@ fn resolve_clause(
|
||||
}));
|
||||
}
|
||||
("compilation", EntityKind::Album) => {
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Album);
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
|
||||
}
|
||||
("compilation", EntityKind::Track) => {
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Track);
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
|
||||
}
|
||||
("compilation", _) => return Ok(None),
|
||||
// `text` is handled by the entity builder (FTS / LIKE), never here.
|
||||
@@ -1156,21 +1156,19 @@ fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
|
||||
}
|
||||
|
||||
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
|
||||
let track_artist: Option<String> = r.get(3)?;
|
||||
let album_artist: Option<String> = r.get(5)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(track_artist, album_artist),
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
song_count: Some(r.get(6)?),
|
||||
duration_sec: Some(r.get(7)?),
|
||||
year: r.get(8)?,
|
||||
genre: r.get(9)?,
|
||||
cover_art_id: r.get(10)?,
|
||||
starred_at: r.get(11)?,
|
||||
synced_at: r.get(12)?,
|
||||
song_count: Some(r.get(5)?),
|
||||
duration_sec: Some(r.get(6)?),
|
||||
year: r.get(7)?,
|
||||
genre: r.get(8)?,
|
||||
cover_art_id: r.get(9)?,
|
||||
starred_at: r.get(10)?,
|
||||
synced_at: r.get(11)?,
|
||||
raw_json: Value::Null,
|
||||
})
|
||||
}
|
||||
@@ -1266,21 +1264,9 @@ fn compilation_filter_fragment(
|
||||
field: &str,
|
||||
op: FilterOp,
|
||||
value: Option<&Value>,
|
||||
kind: EntityKind,
|
||||
table_alias: &str,
|
||||
) -> Result<Option<SqlFragment>, String> {
|
||||
let comp_sql = match kind {
|
||||
EntityKind::Album => crate::album_compilation_filter::compilation_predicate_sql(
|
||||
"a",
|
||||
Some("a.artist"),
|
||||
None,
|
||||
),
|
||||
EntityKind::Track => crate::album_compilation_filter::compilation_predicate_sql(
|
||||
"t",
|
||||
Some("t.artist"),
|
||||
Some("t.album_artist"),
|
||||
),
|
||||
_ => crate::album_compilation_filter::compilation_raw_json_sql("t"),
|
||||
};
|
||||
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
|
||||
match op {
|
||||
FilterOp::IsTrue => Ok(Some(SqlFragment {
|
||||
sql: comp_sql,
|
||||
@@ -2046,27 +2032,6 @@ mod tests {
|
||||
assert_eq!(resp.albums[0].id, "al_comp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_matches_va_album_artist_on_track_groups() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut comp = track("s1", "t_comp", "Hit", "Alice", "Comp Album");
|
||||
comp.album_id = Some("al_comp".into());
|
||||
comp.album_artist = Some("Various Artists".into());
|
||||
comp.raw_json = "{}".into();
|
||||
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
|
||||
reg.album_id = Some("al_reg".into());
|
||||
reg.raw_json = "{}".into();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[comp, reg])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al_comp");
|
||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_on_track_grouped_album_browse() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
|
||||
//! `isCompilation`, or `releaseTypes` containing `Compilation`), plus the same
|
||||
//! "Various Artists" heuristics the web UI uses when structured flags are absent.
|
||||
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
|
||||
|
||||
/// SQL predicate on any row with a `raw_json` column (album or track).
|
||||
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
|
||||
@@ -18,49 +17,6 @@ pub fn compilation_raw_json_sql(table_alias: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn various_artists_like_sql(column: &str) -> String {
|
||||
format!(
|
||||
"lower(trim(coalesce({column}, ''))) LIKE '%various artists%'",
|
||||
column = column
|
||||
)
|
||||
}
|
||||
|
||||
/// Full compilation predicate for browse filters — JSON flags plus VA artist labels.
|
||||
pub fn compilation_predicate_sql(
|
||||
table_alias: &str,
|
||||
artist_column: Option<&str>,
|
||||
album_artist_column: Option<&str>,
|
||||
) -> String {
|
||||
let mut parts = vec![compilation_raw_json_sql(table_alias)];
|
||||
parts.push(format!(
|
||||
"lower(trim(coalesce(json_extract({a}.raw_json, '$.displayArtist'), ''))) LIKE '%various artists%'",
|
||||
a = table_alias
|
||||
));
|
||||
if let Some(col) = artist_column {
|
||||
parts.push(various_artists_like_sql(col));
|
||||
}
|
||||
if let Some(col) = album_artist_column {
|
||||
parts.push(various_artists_like_sql(col));
|
||||
}
|
||||
format!("({})", parts.join(" OR "))
|
||||
}
|
||||
|
||||
pub fn various_artists_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().contains("various artists")
|
||||
}
|
||||
|
||||
/// Track-grouped album rows: prefer album artist when it marks a VA compilation.
|
||||
pub fn pick_album_group_artist(
|
||||
track_artist: Option<String>,
|
||||
album_artist: Option<String>,
|
||||
) -> Option<String> {
|
||||
let aa = album_artist.as_deref().unwrap_or("").trim();
|
||||
if various_artists_label(aa) {
|
||||
return Some(aa.to_string());
|
||||
}
|
||||
track_artist
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -71,24 +27,4 @@ mod tests {
|
||||
assert!(sql.contains("$.compilation"));
|
||||
assert!(sql.contains("$.releaseTypes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_includes_artist_columns() {
|
||||
let sql = compilation_predicate_sql("t", Some("t.artist"), Some("t.album_artist"));
|
||||
assert!(sql.contains("t.artist"));
|
||||
assert!(sql.contains("t.album_artist"));
|
||||
assert!(sql.contains("$.displayArtist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_album_group_artist_prefers_va_album_artist() {
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
|
||||
Some("Various Artists".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
|
||||
Some("Alice".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn reconcile_album_stars(
|
||||
) -> Result<(), String> {
|
||||
runtime
|
||||
.store
|
||||
.with_conn("browse.reconcile_album_stars", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
if starred.is_empty() {
|
||||
conn.execute(
|
||||
"UPDATE album SET starred_at = NULL \
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn link_all_tracks_for_server(
|
||||
server_id: &str,
|
||||
now: i64,
|
||||
) -> Result<u32, String> {
|
||||
store.with_conn_mut("canonical.link_all_tracks", |conn| {
|
||||
store.with_conn_mut("misc", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, isrc, mbid_recording FROM track \
|
||||
|
||||
@@ -476,7 +476,7 @@ pub async fn library_get_offline_path(
|
||||
) -> Result<OfflinePathDto, String> {
|
||||
let path = runtime
|
||||
.store
|
||||
.with_conn("cmd.get_offline_path", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT local_path FROM track_offline \
|
||||
WHERE server_id = ?1 AND track_id = ?2",
|
||||
@@ -1028,7 +1028,7 @@ pub fn patch_content_hash(
|
||||
}
|
||||
runtime
|
||||
.store
|
||||
.with_conn("cmd.patch_content_hash", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE track SET content_hash = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
@@ -1075,7 +1075,7 @@ pub(crate) fn apply_track_patch(
|
||||
|
||||
runtime
|
||||
.store
|
||||
.with_conn("cmd.patch_track", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
// One UPDATE per field present — keeps SQL simple and
|
||||
// matches the spec's per-field patch semantics.
|
||||
if let Some(v) = starred_at {
|
||||
@@ -1211,7 +1211,7 @@ pub fn library_purge_server(
|
||||
let mut report = PurgeReportDto::default();
|
||||
runtime
|
||||
.store
|
||||
.with_conn_mut("cmd.purge_server", |conn| {
|
||||
.with_conn_mut("misc", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let track_count: i64 =
|
||||
tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?;
|
||||
|
||||
@@ -29,7 +29,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
/// if none exists. All non-PK columns fall back to their schema DEFAULTs
|
||||
/// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …).
|
||||
pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.ensure", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)",
|
||||
params![server_id, library_scope],
|
||||
@@ -73,7 +73,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
cursor: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?;
|
||||
self.store.with_conn("sync_state.set_initial_sync_cursor", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -134,7 +134,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
flags: u32,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_capability_flags", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -192,7 +192,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
phase: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_sync_phase", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, sync_phase) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -212,7 +212,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_scan_iso: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_server_last_scan_iso", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -232,7 +232,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -330,7 +330,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_next_poll_at", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, next_poll_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -375,7 +375,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
stats: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(stats).map_err(|e| e.to_string())?;
|
||||
self.store.with_conn("sync_state.set_poll_stats_json", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -412,7 +412,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_local_track_count", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -447,7 +447,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_server_track_count", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -488,7 +488,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
unreliable: bool,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -508,7 +508,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_last_full_sync_at", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -528,7 +528,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_last_delta_sync_at", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -549,7 +549,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -570,7 +570,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
tier: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_library_tier", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, library_tier) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
|
||||
@@ -191,7 +191,7 @@ impl<'a> TrackRepository<'a> {
|
||||
|
||||
/// Next generation stamp for a full-resync orphan sweep on this server.
|
||||
pub fn next_resync_gen(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_conn("track.next_resync_gen", |c| {
|
||||
self.store.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT COALESCE(MAX(resync_gen), 0) + 1 FROM track WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
@@ -203,7 +203,7 @@ impl<'a> TrackRepository<'a> {
|
||||
/// IS-7 — soft-delete live rows not re-stamped during the active resync.
|
||||
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
|
||||
let now = now_unix_ms();
|
||||
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
|
||||
let changed = self.store.with_conn_mut("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
|
||||
@@ -418,7 +418,7 @@ impl<'a> TrackRepository<'a> {
|
||||
if rows.is_empty() {
|
||||
return Ok(RemapStats::default());
|
||||
}
|
||||
self.store.with_conn_mut("track.upsert_batch_remap", |conn| {
|
||||
self.store.with_conn_mut("misc", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut remapped: Vec<RemapEntry> = Vec::new();
|
||||
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
|
||||
|
||||
@@ -26,7 +26,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
|
||||
server_id: &str,
|
||||
old_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
self.store.with_conn("track_id_history.lookup", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT new_id FROM track_id_history \
|
||||
WHERE server_id = ?1 AND old_id = ?2",
|
||||
@@ -40,7 +40,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
|
||||
/// Count the rows recorded for this server — used by tests and by
|
||||
/// post-sync diagnostics (Settings „Library index" panel later).
|
||||
pub fn count_for_server(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_conn("track_id_history.count", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
|
||||
@@ -103,11 +103,6 @@ impl LibraryStore {
|
||||
}
|
||||
|
||||
/// Writer connection — sync ingest, migrations, mutations.
|
||||
///
|
||||
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
|
||||
/// stable `module.action` label (e.g. `sync_state.set_sync_phase`,
|
||||
/// `track.upsert_batch_remap`), not the generic `"misc"`, so production
|
||||
/// stalls can be attributed to a specific call site.
|
||||
pub(crate) fn with_conn<R>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
|
||||
@@ -284,7 +284,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
|
||||
self.store
|
||||
.with_conn("delta.local_track_watermark", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT MAX(server_updated_at) FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0",
|
||||
@@ -297,7 +297,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("delta.local_album_ids", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT DISTINCT album_id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
|
||||
|
||||
@@ -115,7 +115,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn next_candidates(&self, budget: u32) -> Result<Vec<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.next_candidates", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
@@ -133,7 +133,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn mark_deleted(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.mark_deleted", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
@@ -146,7 +146,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn mark_synced(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.mark_synced", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
|
||||
@@ -97,10 +97,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE,
|
||||
)
|
||||
.with_denylist(&["mini"])
|
||||
.build()
|
||||
)
|
||||
@@ -629,24 +625,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if webview.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
match payload.event() {
|
||||
tauri::webview::PageLoadEvent::Started => {
|
||||
let window = webview.window().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(48));
|
||||
let _ = window.show();
|
||||
});
|
||||
}
|
||||
tauri::webview::PageLoadEvent::Finished => {
|
||||
let _ = webview.window().show();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
theme_import::import_theme_zip,
|
||||
|
||||
@@ -49,8 +49,8 @@ pub(crate) struct LogTailDto {
|
||||
pub dropped: bool,
|
||||
}
|
||||
|
||||
/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab.
|
||||
/// `after_seq` is the highest seq the UI already has (omit for
|
||||
/// Incremental tail of the in-memory runtime log buffer for the Performance
|
||||
/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for
|
||||
/// the initial fetch of the most recent `max` lines).
|
||||
#[tauri::command]
|
||||
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
|
||||
|
||||
@@ -15,11 +15,8 @@ pub fn set_nvidia_quirk_active(active: bool) {
|
||||
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
|
||||
}
|
||||
|
||||
/// Whether the Nvidia WebKit quirk was needed at startup. Linux-only: read
|
||||
/// solely by the Linux branch of `theme_animation_risk`. False when unrecorded
|
||||
/// (GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`). Gated so it is
|
||||
/// not dead code on Windows/macOS, where the quirk never applies.
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Whether the Nvidia WebKit quirk was needed at startup. False when unrecorded
|
||||
/// (non-Linux, or GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`).
|
||||
pub(crate) fn nvidia_quirk_active() -> bool {
|
||||
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": false,
|
||||
"backgroundColor": "#1e1e2e",
|
||||
"visible": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicOpenSubsonic', () => ({
|
||||
fetchOpenSubsonicExtensionsWithCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
|
||||
import { scheduleInstantMixProbeForServer } from './subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
|
||||
|
||||
const fetchMock = vi.mocked(fetchOpenSubsonicExtensionsWithCredentials);
|
||||
const SID = 'srv-probe';
|
||||
const id062: SubsonicServerIdentity = { type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true };
|
||||
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
function reset() {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: {},
|
||||
audiomusePluginProbeByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
||||
reset();
|
||||
});
|
||||
|
||||
it('probes once, caches the result, then skips on the next poll', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-probes when forced (user-initiated refresh)', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062, true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-probes after a prior error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(null);
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('error');
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
|
||||
beforeEach(reset);
|
||||
|
||||
it('clears cached probes on a version change but keeps the opt-in', () => {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: { [SID]: id062 },
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
audiomuseNavidromeByServer: { [SID]: true },
|
||||
} as never);
|
||||
|
||||
useAuthStore.getState().setSubsonicServerIdentity(SID, { type: 'navidrome', serverVersion: '0.63.0', openSubsonic: true });
|
||||
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
|
||||
expect(s.audiomuseNavidromeByServer[SID]).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps cached probes when the identity is unchanged', () => {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: { [SID]: id062 },
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
} as never);
|
||||
|
||||
useAuthStore.getState().setSubsonicServerIdentity(SID, { ...id062 });
|
||||
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
});
|
||||
});
|
||||
+11
-78
@@ -2,34 +2,17 @@ import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/server/subsonicServerIdentity';
|
||||
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
|
||||
import { buildCapabilityContext } from '../serverCapabilities/context';
|
||||
import {
|
||||
PROBE_LEGACY_INSTANT_MIX,
|
||||
PROBE_OPENSUBSONIC_EXTENSIONS,
|
||||
SERVER_CAPABILITY_CATALOG,
|
||||
SONIC_SIMILARITY_EXTENSION,
|
||||
} from '../serverCapabilities/catalog';
|
||||
import { neededProbeIds } from '../serverCapabilities/resolve';
|
||||
import {
|
||||
SUBSONIC_CLIENT,
|
||||
SUBSONIC_API_VERSION,
|
||||
api,
|
||||
apiWithCredentials,
|
||||
secureRandomSalt,
|
||||
} from './subsonicClient';
|
||||
import type { PingFailure, PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
/** Map a Subsonic error code to a coarse failure category for the UI. */
|
||||
function classifyPingError(code: number | undefined, message: string | undefined): PingFailure {
|
||||
let reason: PingFailure['reason'] = 'server';
|
||||
if (code === 40 || code === 41 || code === 50) reason = 'auth';
|
||||
else if (code === 20 || code === 30) reason = 'version';
|
||||
return { reason, code, message };
|
||||
}
|
||||
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function ping(): Promise<boolean> {
|
||||
try {
|
||||
@@ -52,33 +35,21 @@ export async function pingWithCredentials(
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' },
|
||||
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
const identity = {
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
if (ok) return { ok: true, ...identity };
|
||||
// Reachable server that rejected the ping — keep the Subsonic reason so the
|
||||
// UI can show a specific message (code 30 = protocol too high, 40 = bad
|
||||
// credentials, …) instead of an opaque failure.
|
||||
const code = typeof data?.error?.code === 'number' ? data.error.code : undefined;
|
||||
const message = typeof data?.error?.message === 'string' ? data.error.message : undefined;
|
||||
console.warn('[psysonic] ping rejected by server:', serverUrl, 'sentVersion=', SUBSONIC_API_VERSION, data?.error ?? data);
|
||||
return { ok: false, failure: classifyPingError(code, message), ...identity };
|
||||
} catch (err) {
|
||||
// Never reached the server (DNS, refused, timeout, TLS-cert not trusted, or
|
||||
// a blocked cross-origin request). The WebView hides the exact cause, so
|
||||
// pass the raw detail through for the toast + log.
|
||||
const detail =
|
||||
(err as { message?: string })?.message || (err as { code?: string })?.code || 'network error';
|
||||
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
|
||||
return { ok: false, failure: { reason: 'network', message: String(detail) } };
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,54 +104,16 @@ export async function probeInstantMixWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful ping, run the server-capability probes needed by the catalog
|
||||
* (`serverCapabilities/`). Which probes run is decided by the strategies eligible
|
||||
* for this server generation — not by inline version checks here.
|
||||
*
|
||||
* Currently: Navidrome ≥ 0.62 → `getOpenSubsonicExtensions` (sonicSimilarity);
|
||||
* Navidrome 0.60–0.61 → legacy `getSimilarSongs` Instant Mix probe.
|
||||
*
|
||||
* Idempotent: a server's advertised capabilities are static within a session, so
|
||||
* once a definitive result is cached the probe is skipped. This is called on every
|
||||
* 120 s connection poll, so re-fetching each time would be wasteful and would flip
|
||||
* the resolved status (and the routed endpoint) through a `probing` window. Pass
|
||||
* `force` for user-initiated refreshes (add/edit/test server); a server version or
|
||||
* type change clears the cache (see `setSubsonicServerIdentity`), forcing a re-probe.
|
||||
*/
|
||||
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
|
||||
export function scheduleInstantMixProbeForServer(
|
||||
serverId: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
identity: SubsonicServerIdentity,
|
||||
force = false,
|
||||
): void {
|
||||
const ctx = buildCapabilityContext(identity);
|
||||
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
||||
const cached = store.audiomusePluginProbeByServer[serverId];
|
||||
// Re-probe only without a definitive cached result (or on force / prior error).
|
||||
// `probing` means an in-flight fetch — skip to avoid a duplicate request.
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
store.setAudiomusePluginProbe(serverId, 'probing');
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
|
||||
const result = extensions === null
|
||||
? 'error'
|
||||
: extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent';
|
||||
useAuthStore.getState().setAudiomusePluginProbe(serverId, result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
||||
const cached = store.instantMixProbeByServer[serverId];
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: vi.fn(),
|
||||
libraryFilterParams: () => ({}),
|
||||
libraryFilterParamsForServer: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('./subsonicLibrary', () => ({
|
||||
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
|
||||
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
|
||||
similarSongsRequestCount: (count: number) => count,
|
||||
}));
|
||||
|
||||
import { api } from './subsonicClient';
|
||||
import { fetchSimilarTracksRouted } from './subsonicArtists';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const SID = 'srv-router';
|
||||
const apiMock = vi.mocked(api);
|
||||
|
||||
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
|
||||
useAuthStore.setState({
|
||||
activeServerId: SID,
|
||||
subsonicServerIdentityByServer: { [SID]: identity as never },
|
||||
audiomusePluginProbeByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
...probes,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] };
|
||||
const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } };
|
||||
|
||||
describe('fetchSimilarTracksRouted', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
});
|
||||
apiMock.mockImplementation(async (endpoint: string) =>
|
||||
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['sonic-1']);
|
||||
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
|
||||
it('falls back to legacy when sonic returns empty', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
});
|
||||
apiMock.mockImplementation(async (endpoint: string) =>
|
||||
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['legacy-1']);
|
||||
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
|
||||
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'absent' },
|
||||
});
|
||||
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['legacy-1']);
|
||||
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { filterSongsToServerLibrary } from './subsonicLibrary';
|
||||
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
|
||||
import {
|
||||
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
|
||||
OP_SIMILAR_TRACKS,
|
||||
} from '../serverCapabilities/catalog';
|
||||
import { resolveCallRoutesForServer } from '../serverCapabilities/storeView';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
@@ -114,45 +109,3 @@ export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicS
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonic (audio-analysis) similar tracks via the OpenSubsonic `sonicSimilarity`
|
||||
* extension (Navidrome ≥ 0.62 + AudioMuse plugin). Returns `[]` when the server
|
||||
* has no provider (HTTP 404) so callers can fall back.
|
||||
*/
|
||||
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
|
||||
'getSonicSimilarTracks.view',
|
||||
{ id, count: requestCount, ...libraryFilterParams() },
|
||||
);
|
||||
const raw = data.sonicMatch;
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
|
||||
if (songs.length === 0) return [];
|
||||
const filtered = await filterSongsToActiveLibrary(songs);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability-routed similar tracks for the active server. Prefers the sonic
|
||||
* similarity endpoint when the AudioMuse plugin is detected (Navidrome ≥ 0.62),
|
||||
* falling back to legacy `getSimilarSongs` on empty/unavailable.
|
||||
*/
|
||||
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
if (!activeServerId) return getSimilarSongs(songId, count);
|
||||
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
|
||||
if (routes.length === 0) return getSimilarSongs(songId, count);
|
||||
for (const route of routes) {
|
||||
const songs = route.transport === 'opensubsonic'
|
||||
? await getSonicSimilarTracks(songId, count)
|
||||
: await getSimilarSongs(songId, count);
|
||||
if (songs.length > 0) return songs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -8,15 +8,6 @@ import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/s
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
|
||||
/**
|
||||
* Subsonic REST protocol version sent on every request. A server returns
|
||||
* error 30 ("Incompatible REST protocol version") if the client asks for a
|
||||
* HIGHER version than it supports — surfaced to the user via the connection
|
||||
* error toast. OpenSubsonic extensions are negotiated via the `openSubsonic`
|
||||
* response flag, not this number.
|
||||
*/
|
||||
export const SUBSONIC_API_VERSION = '1.16.1';
|
||||
|
||||
export function secureRandomSalt(): string {
|
||||
const buf = new Uint8Array(8);
|
||||
crypto.getRandomValues(buf);
|
||||
@@ -26,7 +17,7 @@ export function secureRandomSalt(): string {
|
||||
export function getAuthParams(username: string, password: string) {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' };
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
|
||||
}
|
||||
|
||||
export function restBaseFromUrl(serverUrl: string): string {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
fetchOpenSubsonicExtensionsWithCredentials,
|
||||
hasOpenSubsonicExtension,
|
||||
parseOpenSubsonicExtensions,
|
||||
} from './subsonicOpenSubsonic';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
function okExtensions(extensions: unknown[]) {
|
||||
return {
|
||||
data: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
openSubsonic: true,
|
||||
openSubsonicExtensions: extensions,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseOpenSubsonicExtensions', () => {
|
||||
it('parses extension names and versions', () => {
|
||||
const parsed = parseOpenSubsonicExtensions([
|
||||
{ name: 'sonicSimilarity', versions: [1] },
|
||||
{ name: 'playbackReport', versions: [1, 2] },
|
||||
{ bad: true },
|
||||
]);
|
||||
expect(parsed).toEqual([
|
||||
{ name: 'sonicSimilarity', versions: [1] },
|
||||
{ name: 'playbackReport', versions: [1, 2] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOpenSubsonicExtension', () => {
|
||||
it('detects sonicSimilarity', () => {
|
||||
const extensions = parseOpenSubsonicExtensions([{ name: 'sonicSimilarity', versions: [1] }]);
|
||||
expect(hasOpenSubsonicExtension(extensions, 'sonicSimilarity')).toBe(true);
|
||||
expect(hasOpenSubsonicExtension(extensions, 'other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the advertised extension names', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okExtensions([{ name: 'sonicSimilarity', versions: [1] }, { name: 'playbackReport', versions: [1] }]),
|
||||
);
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toEqual(['sonicSimilarity', 'playbackReport']);
|
||||
});
|
||||
|
||||
it('returns an empty list when none are advertised', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null on request failure', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
|
||||
export interface OpenSubsonicExtension {
|
||||
name: string;
|
||||
versions: number[];
|
||||
}
|
||||
|
||||
export function parseOpenSubsonicExtensions(raw: unknown): OpenSubsonicExtension[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object' && !Array.isArray(entry))
|
||||
.map(entry => ({
|
||||
name: typeof entry.name === 'string' ? entry.name : '',
|
||||
versions: Array.isArray(entry.versions)
|
||||
? entry.versions.filter((v): v is number => typeof v === 'number')
|
||||
: [],
|
||||
}))
|
||||
.filter(entry => entry.name.length > 0);
|
||||
}
|
||||
|
||||
export function hasOpenSubsonicExtension(extensions: readonly OpenSubsonicExtension[], name: string): boolean {
|
||||
return extensions.some(ext => ext.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of OpenSubsonic extension names advertised by the server, or
|
||||
* `null` when the request fails. Shared probe for any extension-gated feature.
|
||||
*/
|
||||
export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
12000,
|
||||
);
|
||||
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { CoverArtTier } from '../cover/types';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, SUBSONIC_API_VERSION, secureRandomSalt } from './subsonicClient';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||
|
||||
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
|
||||
const salt = secureRandomSalt();
|
||||
@@ -16,7 +16,7 @@ function coverArtQueryParams(username: string, password: string, id: string, siz
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: SUBSONIC_API_VERSION,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
@@ -36,7 +36,7 @@ function streamUrlFromProfile(
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: SUBSONIC_API_VERSION,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export function buildDownloadUrl(id: string): string {
|
||||
const p = new URLSearchParams({
|
||||
id,
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
return `${baseUrl}/rest/download.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
@@ -201,20 +201,7 @@ export interface SubsonicDirectory {
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export interface PingFailure {
|
||||
/** Coarse category that drives the user-facing message. */
|
||||
reason: 'auth' | 'version' | 'network' | 'server';
|
||||
/** Subsonic error code, when the server returned a structured error. */
|
||||
code?: number;
|
||||
/** Server's error message, or the network/TLS error detail. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & {
|
||||
ok: boolean;
|
||||
/** Populated when `ok` is false — why the ping was rejected. */
|
||||
failure?: PingFailure;
|
||||
};
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
|
||||
export interface RandomSongsFilters {
|
||||
size?: number;
|
||||
|
||||
@@ -23,6 +23,7 @@ import GlobalConfirmModal from '../components/GlobalConfirmModal';
|
||||
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
|
||||
import OrbitAccountPicker from '../components/OrbitAccountPicker';
|
||||
import OrbitHelpModal from '../components/OrbitHelpModal';
|
||||
import OrbitReconnectModal from '../components/OrbitReconnectModal';
|
||||
import TooltipPortal from '../components/TooltipPortal';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import {
|
||||
@@ -322,6 +323,7 @@ export function AppShell() {
|
||||
<ThemeMigrationNotice />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
<OrbitReconnectModal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
<AppUpdater />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
|
||||
import { configureStartupSplash } from './startupSplash';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '../utils/themes/themeMigration';
|
||||
@@ -113,7 +112,6 @@ export function runPreReactBootstrap(): void {
|
||||
migrateThemeSelection();
|
||||
// Paint the correct theme on the very first frame (no Mocha flash).
|
||||
applyThemeAtStartup();
|
||||
configureStartupSplash();
|
||||
installCrossWindowThemeSync();
|
||||
markDevBuildDocument();
|
||||
pushUserAgentToBackend();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
STARTUP_SPLASH_ID,
|
||||
configureStartupSplash,
|
||||
dismissStartupSplash,
|
||||
} from './startupSplash';
|
||||
|
||||
vi.mock('./windowKind', () => ({
|
||||
getWindowKind: vi.fn(() => 'main'),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/themes/startupThemeAppearance', () => ({
|
||||
applyStartupSplashThemeFromStorage: vi.fn(() => 'mocha'),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/webviewWindow', () => ({
|
||||
getCurrentWebviewWindow: vi.fn(() => ({ show: vi.fn(() => Promise.resolve()) })),
|
||||
}));
|
||||
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
|
||||
|
||||
describe('startupSplash', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root"></div>`;
|
||||
vi.mocked(getWindowKind).mockReturnValue('main');
|
||||
vi.mocked(applyStartupSplashThemeFromStorage).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('removes splash on mini player webview', () => {
|
||||
vi.mocked(getWindowKind).mockReturnValue('mini');
|
||||
configureStartupSplash();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('re-applies theme from storage on main window', () => {
|
||||
configureStartupSplash();
|
||||
expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fades out and removes splash', () => {
|
||||
vi.useFakeTimers();
|
||||
dismissStartupSplash();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)?.classList.contains('app-startup-splash--hide')).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
|
||||
import { getWindowKind } from './windowKind';
|
||||
|
||||
export const STARTUP_SPLASH_ID = 'app-startup-splash';
|
||||
|
||||
/** Ensure the native shell is visible once the webview bundle is alive. */
|
||||
export function revealStartupWindow(): void {
|
||||
if (getWindowKind() === 'mini') return;
|
||||
void getCurrentWebviewWindow().show().catch(() => {});
|
||||
}
|
||||
|
||||
/** Re-apply splash colors after bootstrap theme migration/injection. */
|
||||
export function configureStartupSplash(): void {
|
||||
const splash = document.getElementById(STARTUP_SPLASH_ID);
|
||||
if (!splash) return;
|
||||
|
||||
if (getWindowKind() === 'mini') {
|
||||
splash.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
applyStartupSplashThemeFromStorage();
|
||||
revealStartupWindow();
|
||||
}
|
||||
|
||||
/** Fade out the splash after the first React commit. */
|
||||
export function dismissStartupSplash(): void {
|
||||
const splash = document.getElementById(STARTUP_SPLASH_ID);
|
||||
if (!splash || splash.classList.contains('app-startup-splash--hide')) return;
|
||||
|
||||
splash.classList.add('app-startup-splash--hide');
|
||||
const remove = () => splash.remove();
|
||||
splash.addEventListener('transitionend', remove, { once: true });
|
||||
window.setTimeout(remove, 500);
|
||||
}
|
||||
|
||||
/** Schedule dismiss on the frame after the first paint. */
|
||||
export function scheduleStartupSplashDismiss(): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(dismissStartupSplash);
|
||||
});
|
||||
}
|
||||
@@ -519,13 +519,7 @@ export default function BecauseYouLikeRail({
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// Gate on poolKey (the stable top-anchor identity), not the `pool` array ref.
|
||||
// `pool` is rebuilt whenever Home's mostPlayed changes, so loading more Most
|
||||
// Played albums (which feeds this pool) would otherwise re-run this effect and
|
||||
// swap the cards — a height blip above the row that scroll anchoring turns into
|
||||
// an upward viewport jump. The sibling reserve effect already keys on poolKey.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poolKey, activeServerId, musicLibraryFilterVersion, disableArtwork]);
|
||||
}, [pool, activeServerId, musicLibraryFilterVersion, disableArtwork, poolKey]);
|
||||
|
||||
useLibraryCoverPrefetch(
|
||||
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
|
||||
|
||||
@@ -59,7 +59,7 @@ function LiveOverlayPinnedMetric({
|
||||
);
|
||||
}
|
||||
|
||||
/** FPS + pipeline + pinned live metrics overlay (PsyLab). */
|
||||
/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */
|
||||
export default function FpsOverlay() {
|
||||
const overlayMode = usePerfOverlayMode();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
@@ -66,14 +66,30 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
let style: React.CSSProperties = { display: 'none' };
|
||||
if (anchor) {
|
||||
const vw = window.innerWidth;
|
||||
// Compact, viewport-bounded width (same scale as the other Orbit popovers).
|
||||
const width = Math.min(560, vw - 16);
|
||||
// Anchor under the trigger, but slide inward so the right-anchored popover
|
||||
// never spills off either window edge — on a narrow window the left edge
|
||||
// would otherwise drop off-screen.
|
||||
const right = Math.min(
|
||||
Math.max(8, vw - anchor.right),
|
||||
Math.max(8, vw - width - 8),
|
||||
);
|
||||
style = {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right,
|
||||
width,
|
||||
// Cap height to the space below the anchor (minus a small margin); the
|
||||
// log textarea flex-shrinks to fit. Re-read every render (the 1s
|
||||
// mini-display tick keeps width/height fresh after a window resize).
|
||||
maxHeight: `calc(100vh - ${Math.round(anchor.bottom + 24)}px)`,
|
||||
zIndex: 9999,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Live mini-display data ────────────────────────────────────────────
|
||||
const role = useOrbitStore(s => s.role);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { makeInitialOrbitState } from '../api/orbit';
|
||||
|
||||
const { authState, orbitState, orbitApi } = vi.hoisted(() => ({
|
||||
authState: { isLoggedIn: true, activeServerId: 'srv-1' as string | null, username: 'bob' as string | undefined },
|
||||
orbitState: { role: null as 'host' | 'guest' | null },
|
||||
orbitApi: {
|
||||
readOrbitLastSession: vi.fn(),
|
||||
findSessionPlaylistId: vi.fn(),
|
||||
readOrbitState: vi.fn(),
|
||||
clearOrbitLastSession: vi.fn(),
|
||||
resumeOrbitSessionAsHost: vi.fn(),
|
||||
joinOrbitSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
vi.mock('../store/authStore', () => {
|
||||
const hook = (sel: (s: typeof authState) => unknown) => sel(authState);
|
||||
(hook as unknown as { getState: () => unknown }).getState = () => ({
|
||||
...authState,
|
||||
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
|
||||
});
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
vi.mock('../store/orbitStore', () => {
|
||||
const hook = (sel: (s: typeof orbitState) => unknown) => sel(orbitState);
|
||||
(hook as unknown as { getState: () => unknown }).getState = () => orbitState;
|
||||
return { useOrbitStore: hook };
|
||||
});
|
||||
vi.mock('../utils/ui/toast', () => ({ showToast: vi.fn() }));
|
||||
vi.mock('../utils/orbit', () => ({
|
||||
...orbitApi,
|
||||
ORBIT_RECONNECT_COUNTDOWN_S: 30,
|
||||
ORBIT_RECONNECT_MAX_AGE_MS: 30 * 60_000,
|
||||
}));
|
||||
|
||||
import OrbitReconnectModal from './OrbitReconnectModal';
|
||||
|
||||
const hostBreadcrumb = {
|
||||
sid: 'feedface',
|
||||
sessionPlaylistId: 'pl-1',
|
||||
outboxPlaylistId: 'ob-1',
|
||||
role: 'host' as const,
|
||||
sessionName: 'Night Run',
|
||||
hostUsername: 'bob',
|
||||
serverId: 'srv-1',
|
||||
savedAt: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authState.isLoggedIn = true;
|
||||
authState.activeServerId = 'srv-1';
|
||||
authState.username = 'bob';
|
||||
orbitState.role = null;
|
||||
orbitApi.readOrbitLastSession.mockReturnValue(hostBreadcrumb);
|
||||
orbitApi.findSessionPlaylistId.mockResolvedValue('pl-1');
|
||||
orbitApi.readOrbitState.mockResolvedValue(
|
||||
makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('OrbitReconnectModal', () => {
|
||||
// Regression: StrictMode double-invokes effects (mount → cleanup → mount).
|
||||
// The old one-shot guard let the cancelled first run block the second, so the
|
||||
// prompt never appeared in dev builds. This must survive the double-invoke.
|
||||
it('shows the reconnect prompt under StrictMode for a live host session', async () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
expect(orbitApi.clearOrbitLastSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prompt when the breadcrumb is for a different server', async () => {
|
||||
authState.activeServerId = 'other-server';
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wipes the breadcrumb and stays silent when the session is gone', async () => {
|
||||
orbitApi.findSessionPlaylistId.mockResolvedValue(null);
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await waitFor(() => expect(orbitApi.clearOrbitLastSession).toHaveBeenCalled());
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not prompt while already bound to a session', async () => {
|
||||
orbitState.role = 'host';
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import {
|
||||
clearOrbitLastSession,
|
||||
findSessionPlaylistId,
|
||||
joinOrbitSession,
|
||||
ORBIT_RECONNECT_COUNTDOWN_S,
|
||||
ORBIT_RECONNECT_MAX_AGE_MS,
|
||||
readOrbitLastSession,
|
||||
readOrbitState,
|
||||
resumeOrbitSessionAsHost,
|
||||
type OrbitLastSession,
|
||||
} from '../utils/orbit';
|
||||
|
||||
/**
|
||||
* Orbit — reconnect prompt shown at startup when the app was restarted while a
|
||||
* session was active. The in-memory Orbit store is gone after a restart, but
|
||||
* the `lastSession` breadcrumb survives; this verifies the session is still
|
||||
* alive on the server and offers a one-click rejoin with a countdown that
|
||||
* auto-rejoins when it reaches zero.
|
||||
*
|
||||
* Host breadcrumbs resume hosting (`resumeOrbitSessionAsHost`); guest
|
||||
* breadcrumbs rejoin via the normal `joinOrbitSession` path. Declining /
|
||||
* Escape / a failed attempt wipes the breadcrumb so it isn't offered again.
|
||||
*/
|
||||
export default function OrbitReconnectModal() {
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
|
||||
const [candidate, setCandidate] = useState<OrbitLastSession | null>(null);
|
||||
const [secondsLeft, setSecondsLeft] = useState(ORBIT_RECONNECT_COUNTDOWN_S);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const decidedRef = useRef(false);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
// One-shot startup preflight: is there a live session worth offering?
|
||||
//
|
||||
// StrictMode-safe: `decidedRef` is set only *after* the `cancelled` guard
|
||||
// inside the async, never up front. React's dev double-invoke (mount →
|
||||
// cleanup → mount) cancels the first run before it can decide, so the second
|
||||
// (real) mount still runs the check to completion. A network error leaves
|
||||
// `decidedRef` false so a later dependency change retries.
|
||||
useEffect(() => {
|
||||
if (decidedRef.current) return;
|
||||
if (!isLoggedIn || !activeServerId || orbitRole !== null) return;
|
||||
|
||||
const rec = readOrbitLastSession();
|
||||
if (!rec || rec.serverId !== activeServerId) return; // none / different server
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const sessionPlaylistId = await findSessionPlaylistId(rec.sid);
|
||||
if (cancelled) return;
|
||||
if (!sessionPlaylistId) { decidedRef.current = true; clearOrbitLastSession(); return; }
|
||||
const state = await readOrbitState(sessionPlaylistId);
|
||||
if (cancelled) return;
|
||||
if (!state || state.ended) { decidedRef.current = true; clearOrbitLastSession(); return; }
|
||||
// Too long since the last host snapshot → treat as dead, don't offer.
|
||||
if (Date.now() - (state.positionAt ?? 0) > ORBIT_RECONNECT_MAX_AGE_MS) {
|
||||
decidedRef.current = true; clearOrbitLastSession(); return;
|
||||
}
|
||||
// A host breadcrumb only resumes if we're still the session's host.
|
||||
if (rec.role === 'host' && state.host !== useAuthStore.getState().getActiveServer()?.username) {
|
||||
decidedRef.current = true; clearOrbitLastSession(); return;
|
||||
}
|
||||
decidedRef.current = true;
|
||||
setCandidate(rec);
|
||||
} catch {
|
||||
/* network hiccup — keep the breadcrumb + retry on the next dep change */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isLoggedIn, activeServerId, orbitRole]);
|
||||
|
||||
const doReconnect = useCallback(async () => {
|
||||
if (firedRef.current) return; // guard countdown + manual click racing
|
||||
const rec = candidate;
|
||||
if (!rec) return;
|
||||
firedRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (rec.role === 'host') await resumeOrbitSessionAsHost(rec.sid);
|
||||
else await joinOrbitSession(rec.sid);
|
||||
setCandidate(null); // success → store now bound, modal hides
|
||||
} catch {
|
||||
clearOrbitLastSession();
|
||||
showToast(t('orbit.reconnect.failed'), 4000, 'error');
|
||||
setCandidate(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [candidate, t]);
|
||||
|
||||
const stayOut = useCallback(() => {
|
||||
clearOrbitLastSession();
|
||||
setCandidate(null);
|
||||
}, []);
|
||||
|
||||
// Countdown → auto-rejoin at zero. Paused while a reconnect is in flight.
|
||||
useEffect(() => {
|
||||
if (!candidate || busy) return;
|
||||
if (secondsLeft <= 0) { void doReconnect(); return; }
|
||||
const id = window.setTimeout(() => setSecondsLeft(s => s - 1), 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [candidate, busy, secondsLeft, doReconnect]);
|
||||
|
||||
// Enter → rejoin now; Escape → stay out (explicit dismissal, no auto-rejoin).
|
||||
useEffect(() => {
|
||||
if (!candidate) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void doReconnect(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); stayOut(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [candidate, doReconnect, stayOut]);
|
||||
|
||||
if (!candidate) return null;
|
||||
|
||||
const body = candidate.role === 'host'
|
||||
? t('orbit.reconnect.bodyHost', { name: candidate.sessionName })
|
||||
: t('orbit.reconnect.bodyGuest', { host: candidate.hostUsername, name: candidate.sessionName });
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-exit-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) stayOut(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-reconnect-title"
|
||||
>
|
||||
<div className="modal-content orbit-exit-modal">
|
||||
<h3 id="orbit-reconnect-title" className="orbit-exit-modal__title">{t('orbit.reconnect.title')}</h3>
|
||||
<p className="orbit-exit-modal__body">{body}</p>
|
||||
{!busy && (
|
||||
<p className="orbit-reconnect-countdown">{t('orbit.reconnect.autoIn', { seconds: secondsLeft })}</p>
|
||||
)}
|
||||
<div className="orbit-exit-modal__actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={stayOut} disabled={busy}>
|
||||
{t('orbit.reconnect.stayOut')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void doReconnect()} disabled={busy} autoFocus>
|
||||
{busy ? t('orbit.reconnect.reconnecting') : t('orbit.reconnect.rejoin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -28,8 +28,6 @@ import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { coerceOpenArtistRefs } from '../utils/openArtistRefs';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
|
||||
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
|
||||
@@ -148,8 +146,8 @@ export default function PlayerBar() {
|
||||
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
|
||||
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
|
||||
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
|
||||
const displayArtistRefs = !showPreviewMeta && currentTrack && coerceOpenArtistRefs(currentTrack.artists).length > 0
|
||||
? resolveTrackArtistRefs(currentTrack)
|
||||
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
|
||||
? currentTrack.artists
|
||||
: undefined;
|
||||
|
||||
const coverArtId = showPreviewMeta
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../test/helpers/renderWithProviders';
|
||||
import SongCard from './SongCard';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
|
||||
const navigateToArtist = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useNavigateToArtist', () => ({
|
||||
useNavigateToArtist: () => navigateToArtist,
|
||||
}));
|
||||
|
||||
vi.mock('../cover/useLibraryCoverRef', () => ({
|
||||
useTrackCoverRef: () => undefined,
|
||||
}));
|
||||
|
||||
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
|
||||
return {
|
||||
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
|
||||
...overrides,
|
||||
} as SubsonicSong;
|
||||
}
|
||||
|
||||
describe('SongCard', () => {
|
||||
it('splits OpenSubsonic artists into individual links', async () => {
|
||||
navigateToArtist.mockClear();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<SongCard
|
||||
disableArtwork
|
||||
song={song({
|
||||
artist: 'Apocalyptica', artistId: 'a1',
|
||||
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
|
||||
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
|
||||
await user.click(screen.getByText('Joe Duplantier'));
|
||||
expect(navigateToArtist).toHaveBeenCalledWith('a2');
|
||||
});
|
||||
});
|
||||
+15
-16
@@ -1,6 +1,7 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -12,9 +13,6 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
|
||||
interface SongCardProps {
|
||||
song: SubsonicSong;
|
||||
@@ -33,7 +31,7 @@ function SongCard({
|
||||
}: SongCardProps) {
|
||||
const layoutPx = artworkSize ?? displayCssPx;
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
|
||||
@@ -57,7 +55,12 @@ function SongCard({
|
||||
};
|
||||
|
||||
const handleClick = handlePlay;
|
||||
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
|
||||
|
||||
const handleArtistClick = (e: React.MouseEvent) => {
|
||||
if (!song.artistId) return;
|
||||
e.stopPropagation();
|
||||
navigate(`/artist/${song.artistId}`);
|
||||
};
|
||||
|
||||
const handleAlbumClick = (e: React.MouseEvent) => {
|
||||
if (!song.albumId) return;
|
||||
@@ -139,16 +142,12 @@ function SongCard({
|
||||
</div>
|
||||
<div className="song-card-info">
|
||||
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
|
||||
<p className="song-card-artist truncate" title={song.artist}>
|
||||
<OpenArtistRefInline
|
||||
refs={artistRefs}
|
||||
fallbackName={song.artist}
|
||||
onGoArtist={id => navigateToArtist(id)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="track-artist-link"
|
||||
/>
|
||||
</p>
|
||||
<p
|
||||
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={handleArtistClick}
|
||||
title={song.artist}
|
||||
>{song.artist}</p>
|
||||
{song.albumId && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
interface Props {
|
||||
@@ -40,7 +39,12 @@ function SongRow({ song, showBpm }: Props) {
|
||||
enqueue([songToTrack(song)]);
|
||||
};
|
||||
|
||||
const artistRefs = resolveTrackArtistRefs(song);
|
||||
// Split multi-artist tracks into individually clickable links (OpenSubsonic
|
||||
// `artists[]`), falling back to the single flat artist when absent — mirrors
|
||||
// the album tracklist so a "A · B" credit isn't one link to a single artist.
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.artist }];
|
||||
|
||||
const bpmTooltip =
|
||||
song.localBpmSource === 'analysis'
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Paintbrush, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from '../hooks/useThemeUpdates';
|
||||
|
||||
interface Props {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar pill shown above Now Playing while one or more installed community
|
||||
* themes have a newer version in the store. Clicking opens Settings → Themes;
|
||||
* X dismisses until a new update changes the set (see {@link themeUpdateSignature}).
|
||||
*
|
||||
* Sibling of {@link WhatsNewBanner}; reuses its `.whats-new-banner` styling.
|
||||
*/
|
||||
export default function ThemeUpdateBanner({ collapsed }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const updates = useThemeUpdates();
|
||||
const lastDismissed = useAuthStore(s => s.lastDismissedThemeUpdateSig);
|
||||
const setDismissed = useAuthStore(s => s.setLastDismissedThemeUpdateSig);
|
||||
|
||||
const count = updates.length;
|
||||
const sig = themeUpdateSignature(updates);
|
||||
if (count === 0 || sig === lastDismissed) return null;
|
||||
|
||||
const open = () => navigate('/settings', { state: { tab: 'themes' } });
|
||||
const dismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDismissed(sig);
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="whats-new-banner whats-new-banner--collapsed theme-update-banner--collapsed"
|
||||
onClick={open}
|
||||
data-tooltip={t('sidebar.themeUpdatesTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Paintbrush size={16} />
|
||||
<span className="theme-update-banner__count theme-update-banner__count--dot" aria-hidden>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="whats-new-banner theme-update-banner" onClick={open}>
|
||||
<Paintbrush size={14} className="whats-new-banner__icon" />
|
||||
<span className="whats-new-banner__text">
|
||||
<span className="whats-new-banner__title">{t('sidebar.themeUpdatesTitle')}</span>
|
||||
</span>
|
||||
<span className="theme-update-banner__count" aria-hidden>{count}</span>
|
||||
<span
|
||||
className="whats-new-banner__dismiss"
|
||||
role="button"
|
||||
aria-label={t('sidebar.themeUpdatesDismiss')}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||
|
||||
type ContextMenuFn = (
|
||||
x: number,
|
||||
@@ -138,7 +137,9 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
</div>
|
||||
);
|
||||
case 'artist': {
|
||||
const artistRefs = resolveTrackArtistRefs(song);
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.artist }];
|
||||
return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
{artistRefs.map((a, i) => (
|
||||
|
||||
@@ -8,8 +8,6 @@ import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import StarRating from '../StarRating';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||
|
||||
export interface FavoriteSongRowCallbacks {
|
||||
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
|
||||
@@ -102,15 +100,7 @@ function FavoriteSongRow({
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<OpenArtistRefInline
|
||||
refs={resolveTrackArtistRefs(song)}
|
||||
fallbackName={song.artist}
|
||||
onGoArtist={id => cb.navArtist(id, song.serverId)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="track-artist track-artist-link"
|
||||
separatorClassName="track-artist-sep"
|
||||
/>
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'album': return (
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/helpers/renderWithProviders';
|
||||
import { resetAuthStore } from '../../test/helpers/storeReset';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { FsClock } from './FsClock';
|
||||
|
||||
describe('FsClock', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 1, 21, 59, 0));
|
||||
resetAuthStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses the 24-hour clock format from Settings → System', () => {
|
||||
useAuthStore.setState({ clockFormat: '24h' });
|
||||
renderWithProviders(<FsClock />);
|
||||
expect(screen.getByText('21:59')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/AM|PM/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the 12-hour clock format when selected', () => {
|
||||
useAuthStore.setState({ clockFormat: '12h' });
|
||||
renderWithProviders(<FsClock />);
|
||||
expect(screen.getByText('09:59 PM')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { formatClockTime } from '../../utils/format/formatClockTime';
|
||||
|
||||
function formatClock(): string {
|
||||
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone wall-clock for the fullscreen player. Owns its own state so the
|
||||
@@ -9,19 +10,11 @@ import { formatClockTime } from '../../utils/format/formatClockTime';
|
||||
* the minute rollover) and pauses while the window/tab is hidden.
|
||||
*/
|
||||
export const FsClock = memo(function FsClock() {
|
||||
const clockFormat = useAuthStore(s => s.clockFormat);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const formatNow = () => formatClockTime(Date.now(), clockFormat, i18n.language);
|
||||
const [time, setTime] = useState(formatNow);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(formatNow());
|
||||
}, [clockFormat, i18n.language]);
|
||||
const [time, setTime] = useState(formatClock);
|
||||
|
||||
useEffect(() => {
|
||||
let id: number | undefined;
|
||||
const tick = () => setTime(formatClockTime(Date.now(), clockFormat, i18n.language));
|
||||
const tick = () => setTime(formatClock());
|
||||
const sync = () => {
|
||||
if (document.hidden) {
|
||||
if (id !== undefined) { clearInterval(id); id = undefined; }
|
||||
@@ -36,7 +29,7 @@ export const FsClock = memo(function FsClock() {
|
||||
if (id !== undefined) clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
};
|
||||
}, [clockFormat, i18n.language]);
|
||||
}, []);
|
||||
|
||||
return <span className="fsp-clock">{time}</span>;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||
|
||||
/**
|
||||
* Multi-artist credit for playlist track rows (main list + suggestions).
|
||||
@@ -12,7 +11,9 @@ import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||
*/
|
||||
export function PlaylistArtistCell({ song }: { song: SubsonicSong }) {
|
||||
const navigate = useNavigate();
|
||||
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.artist }];
|
||||
return (
|
||||
<div className="track-artist-cell">
|
||||
{artistRefs.map((a, i) => (
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Variant = 'inline' | 'overlay';
|
||||
|
||||
/**
|
||||
* Small amber "animated theme" chip, shown only on animation-risk setups
|
||||
* (Nvidia/Linux or compositing off). A motion glyph rather than a hazard
|
||||
* triangle — the CPU-usage caveat lives in the tooltip.
|
||||
*
|
||||
* - `inline` — sits after a theme name (Theme Store rows).
|
||||
* - `overlay` — pinned top-centre on an installed theme's preview swatch
|
||||
* (top-right is the active indicator, top-left the uninstall X).
|
||||
*/
|
||||
export function AnimatedThemeBadge({ variant }: { variant: Variant }) {
|
||||
const { t } = useTranslation();
|
||||
const overlay = variant === 'overlay';
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t('settings.themeAnimationWarning')}
|
||||
data-tooltip={t('settings.themeAnimationWarning')}
|
||||
data-tooltip-pos="top"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
width: overlay ? 18 : undefined,
|
||||
height: overlay ? 18 : undefined,
|
||||
padding: overlay ? 0 : '1px 5px',
|
||||
borderRadius: 999,
|
||||
background: 'color-mix(in srgb, var(--warning) 20%, var(--bg-elevated, var(--bg-card)))',
|
||||
border: '1px solid color-mix(in srgb, var(--warning) 45%, transparent)',
|
||||
color: 'var(--warning)',
|
||||
...(overlay
|
||||
? { position: 'absolute', top: 3, left: '50%', transform: 'translateX(-50%)' }
|
||||
: { verticalAlign: 'middle' }),
|
||||
}}
|
||||
>
|
||||
<Activity size={overlay ? 11 : 12} strokeWidth={2.5} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, RefreshCw, X } from 'lucide-react';
|
||||
import { AlertTriangle, Check, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
|
||||
import { useThemeUpdates } from '../../hooks/useThemeUpdates';
|
||||
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
|
||||
import { FIXED_THEMES } from './fixedThemes';
|
||||
|
||||
/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */
|
||||
@@ -47,18 +42,6 @@ export function InstalledThemes() {
|
||||
const setTheme = useThemeStore(s => s.setTheme);
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const animRisk = useThemeAnimationRisk();
|
||||
const updates = useThemeUpdates();
|
||||
const updateById = useMemo(() => new Map(updates.map(u => [u.id, u])), [updates]);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
const th = updateById.get(id);
|
||||
if (!th) return;
|
||||
setUpdatingId(id);
|
||||
const result = await installThemeFromRegistry(th);
|
||||
setUpdatingId(null);
|
||||
if (result !== 'ok') showToast(t('settings.themeStoreInstallFailed'), 4000, 'error');
|
||||
};
|
||||
|
||||
const cards: Card[] = [
|
||||
...FIXED_THEMES.map(f => ({ id: f.id, label: f.label, bg: f.bg, card: f.card, accent: f.accent, fixed: true, accessibility: !!f.accessibility, animated: false })),
|
||||
@@ -97,7 +80,30 @@ export function InstalledThemes() {
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
{animRisk && c.animated && <AnimatedThemeBadge variant="overlay" />}
|
||||
{animRisk && c.animated && (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t('settings.themeAnimationWarning')}
|
||||
data-tooltip={t('settings.themeAnimationWarning')}
|
||||
data-tooltip-pos="top"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '3px',
|
||||
right: '3px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-elevated)',
|
||||
color: 'var(--warning)',
|
||||
border: '1px solid var(--warning)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={10} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
|
||||
{c.label}
|
||||
@@ -149,38 +155,6 @@ export function InstalledThemes() {
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
{updateById.has(c.id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdate(c.id)}
|
||||
disabled={updatingId === c.id}
|
||||
data-tooltip={updatingId === c.id ? t('settings.themeStoreUpdating') : t('settings.themeStoreUpdate')}
|
||||
data-tooltip-pos="top"
|
||||
aria-label={t('settings.themeStoreUpdate')}
|
||||
// Big centered icon overlay across the whole 46px preview so an
|
||||
// available update is impossible to miss; click updates in place.
|
||||
// Icon (not text) so it fits the small card in any language.
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 46,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--text-on-accent, #fff)',
|
||||
cursor: updatingId === c.id ? 'default' : 'pointer',
|
||||
opacity: updatingId === c.id ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={20} strokeWidth={2.5} className={updatingId === c.id ? 'spin' : undefined} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,9 +2,8 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { AlertTriangle, CheckCircle2, Globe, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { formatServerSoftware } from '../../utils/server/subsonicServerIdentity';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
|
||||
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
||||
@@ -12,7 +11,6 @@ import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
|
||||
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import { pingFailureMessage } from '../../utils/server/pingFailureMessage';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
|
||||
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
|
||||
@@ -26,10 +24,7 @@ import {
|
||||
} from '../../utils/server/serverUrlRemigration';
|
||||
import { useConfirmModalStore } from '../../store/confirmModalStore';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from '../sidebar/perfProbe/PerfProbeStatusBadge';
|
||||
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '../../serverCapabilities/catalog';
|
||||
import { resolveFeatureForServer } from '../../serverCapabilities/storeView';
|
||||
import type { CapabilityStatus, ResolvedCapability } from '../../serverCapabilities/types';
|
||||
import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity';
|
||||
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
||||
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
||||
import { switchActiveServer } from '../../utils/server/switchActiveServer';
|
||||
@@ -38,24 +33,6 @@ import { ServerGripHandle } from './ServerGripHandle';
|
||||
|
||||
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||||
|
||||
function audiomuseProbeBadge(
|
||||
status: CapabilityStatus,
|
||||
t: (key: string) => string,
|
||||
): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (status) {
|
||||
case 'present': return { tone: 'ok', label: t('settings.audiomuseStatusActive') };
|
||||
case 'absent': return { tone: 'muted', label: t('settings.audiomuseStatusNotDetected') };
|
||||
case 'error': return { tone: 'error', label: t('settings.audiomuseStatusProbeFailed') };
|
||||
default: return { tone: 'warn', label: t('settings.audiomuseStatusChecking') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Row visibility: hide only when a manual (legacy) strategy proves the feature absent. */
|
||||
function showAudiomuseRow(resolved: ResolvedCapability | null): boolean {
|
||||
if (!resolved || resolved.strategyId === null || resolved.status === 'ineligible') return false;
|
||||
return !(resolved.activation === 'manual' && resolved.status === 'absent');
|
||||
}
|
||||
|
||||
type ServerDropTarget = { idx: number; before: boolean } | null;
|
||||
|
||||
export function ServersTab({
|
||||
@@ -158,7 +135,7 @@ export function ServersTab({
|
||||
openSubsonic: probe.ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(server.id, identity);
|
||||
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity, true);
|
||||
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity);
|
||||
}
|
||||
setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' }));
|
||||
} catch {
|
||||
@@ -227,6 +204,8 @@ export function ServersTab({
|
||||
};
|
||||
|
||||
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
const tempId = '_new';
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||||
try {
|
||||
@@ -240,7 +219,6 @@ export function ServersTab({
|
||||
data.password,
|
||||
);
|
||||
if (!announceVerifyResult(verify)) {
|
||||
// announceVerifyResult already toasts the reason; keep the form open.
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
return;
|
||||
}
|
||||
@@ -254,26 +232,15 @@ export function ServersTab({
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(id, identity);
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (added) void bootstrapIndexedServer(added);
|
||||
// Close the form only once the server actually landed.
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
} else {
|
||||
// Ping failed (reachable server that rejected us, or a network/TLS
|
||||
// problem). Don't drop the add silently: show a specific reason and
|
||||
// keep the form open so the entered details aren't lost.
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
const { key, vars } = pingFailureMessage(ping.failure);
|
||||
console.warn('[servers] add aborted:', ping.failure?.reason ?? 'unknown', data.url, ping.failure);
|
||||
showToast(t(key, vars), 6000, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
console.error('[servers] add failed:', data.url, e);
|
||||
showToast(t('settings.serverFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,7 +324,7 @@ export function ServersTab({
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(id, identity);
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
||||
}
|
||||
setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' }));
|
||||
} catch {
|
||||
@@ -406,7 +373,6 @@ export function ServersTab({
|
||||
const status = connStatus[srv.id];
|
||||
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
|
||||
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
|
||||
const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]);
|
||||
return (
|
||||
<div
|
||||
key={srv.id}
|
||||
@@ -439,24 +405,16 @@ export function ServersTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{serverSoftware && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: '2px', overflow: 'hidden' }}>
|
||||
<Server size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverSoftware}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
{srv.url.startsWith('https://') ? (
|
||||
<Lock size={10} style={{ color: 'var(--positive)', flexShrink: 0 }} />
|
||||
) : (
|
||||
<Globe size={10} style={{ flexShrink: 0 }} />
|
||||
<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: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||
<User size={10} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||
<User size={11} />
|
||||
{srv.username}
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,13 +483,10 @@ export function ServersTab({
|
||||
onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')}
|
||||
onCancel={() => void librarySync.handleCancel()}
|
||||
/>
|
||||
{(() => {
|
||||
const resolved = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
|
||||
if (!showAudiomuseRow(resolved) || !resolved) return null;
|
||||
const autoManaged = resolved.activation === 'auto';
|
||||
const probeBadge = audiomuseProbeBadge(resolved.status, t);
|
||||
const audiomuseActive = !!auth.audiomuseNavidromeByServer[srv.id];
|
||||
return (
|
||||
{showAudiomuseNavidromeServerSetting(
|
||||
auth.subsonicServerIdentityByServer[srv.id],
|
||||
auth.instantMixProbeByServer[srv.id],
|
||||
) && (
|
||||
<div
|
||||
className="settings-toggle-row"
|
||||
data-settings-search={t('settings.audiomuseTitle')}
|
||||
@@ -542,7 +497,7 @@ export function ServersTab({
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{t('settings.audiomuseTitle')}
|
||||
{audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||||
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
|
||||
@@ -551,42 +506,35 @@ export function ServersTab({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!autoManaged && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
|
||||
<Trans
|
||||
i18nKey="settings.audiomuseDesc"
|
||||
components={{
|
||||
pluginLink: (
|
||||
<a
|
||||
href={AUDIOMUSE_NV_PLUGIN_URL}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
|
||||
}}
|
||||
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
|
||||
<Trans
|
||||
i18nKey="settings.audiomuseDesc"
|
||||
components={{
|
||||
pluginLink: (
|
||||
<a
|
||||
href={AUDIOMUSE_NV_PLUGIN_URL}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
|
||||
}}
|
||||
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{autoManaged ? (
|
||||
<PerfProbeStatusBadge tone={probeBadge.tone}>{probeBadge.label}</PerfProbeStatusBadge>
|
||||
) : (
|
||||
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={audiomuseActive}
|
||||
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!auth.audiomuseNavidromeByServer[srv.id]}
|
||||
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/utils/themes/themeRegistry';
|
||||
import type { FetchRegistryResult, Registry } from '@/utils/themes/themeRegistry';
|
||||
|
||||
// Control the registry the store browses so pagination/refresh are deterministic.
|
||||
vi.mock('@/utils/themes/themeRegistry', () => ({
|
||||
fetchRegistry: vi.fn(),
|
||||
fetchThemeCss: vi.fn(async () => 'css'),
|
||||
assetUrl: (p: string) => `https://raw.example/${p}`,
|
||||
cdnUrl: (p: string) => `https://cdn.example/${p}`,
|
||||
}));
|
||||
|
||||
import { fetchRegistry } from '@/utils/themes/themeRegistry';
|
||||
@@ -38,40 +38,6 @@ function makeRegistry(n: number): Registry {
|
||||
|
||||
const rows = (container: HTMLElement) => container.querySelectorAll('.theme-store-row');
|
||||
|
||||
/** Visible theme names in row order (the first span in each row is the name). */
|
||||
const rowNames = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll('.theme-store-row')].map(r => r.querySelector('span')?.textContent);
|
||||
|
||||
function mkTheme(id: string, name: string, extra: Partial<RegistryTheme> = {}): RegistryTheme {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
author: 'Tester',
|
||||
version: '1.0.0',
|
||||
description: `Description ${id}`,
|
||||
mode: 'dark',
|
||||
css: `themes/${id}/theme.css`,
|
||||
thumbnail: `themes/${id}/thumb.webp`,
|
||||
tags: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function registryOf(themes: RegistryTheme[]): Registry {
|
||||
return { schemaVersion: 1, generatedAt: '2026-06-08T00:00:00Z', themes };
|
||||
}
|
||||
|
||||
/** Open the sort dropdown and pick an option by its visible label. */
|
||||
async function selectSort(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
container: HTMLElement,
|
||||
optionLabel: string,
|
||||
) {
|
||||
await user.click(container.querySelector('.custom-select-trigger') as HTMLElement);
|
||||
// The option closes the list on mousedown, so fire it directly.
|
||||
fireEvent.mouseDown(await screen.findByRole('option', { name: optionLabel }));
|
||||
}
|
||||
|
||||
describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -86,8 +52,7 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
await screen.findByText('Theme 01');
|
||||
// PAGE_SIZE is 12 → 30 themes span 3 pages.
|
||||
expect(rows(container)).toHaveLength(12);
|
||||
expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 1 of 3')).toBeInTheDocument();
|
||||
// Page-2 themes are not rendered yet.
|
||||
expect(screen.queryByText('Theme 13')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -111,13 +76,13 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
expect(screen.getByLabelText('Previous page')).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByLabelText('Next page'));
|
||||
expect(screen.getByRole('button', { name: '2', current: 'page' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Theme 13')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Previous page')).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByLabelText('Next page'));
|
||||
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 3 of 3')).toBeInTheDocument();
|
||||
expect(rows(container)).toHaveLength(6); // 30 - 24
|
||||
expect(screen.getByLabelText('Next page')).toBeDisabled();
|
||||
});
|
||||
@@ -142,12 +107,12 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
|
||||
await screen.findByText('Theme 01');
|
||||
await user.click(screen.getByLabelText('Next page'));
|
||||
expect(screen.getByRole('button', { name: '2', current: 'page' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
|
||||
|
||||
// 'theme' matches all 30 names, so the catalogue is unchanged in size — but
|
||||
// the page must snap back to 1 so the user isn't stranded past the end.
|
||||
await user.type(screen.getByRole('searchbox'), 'theme');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Page 1 of 3')).toBeInTheDocument());
|
||||
expect(screen.getByText('Theme 01')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -188,37 +153,4 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to newest and sorts alphabetically', async () => {
|
||||
const themes = [
|
||||
mkTheme('a', 'Alpha', { updatedAt: '2026-06-01T00:00:00Z' }),
|
||||
mkTheme('b', 'Bravo', { updatedAt: '2026-06-05T00:00:00Z' }),
|
||||
mkTheme('c', 'Charlie', { updatedAt: '2026-06-03T00:00:00Z' }),
|
||||
];
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
|
||||
const { container } = renderWithProviders(<ThemeStoreSection />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await screen.findByText('Bravo');
|
||||
// Default sort is newest first: Bravo (06-05) > Charlie (06-03) > Alpha (06-01).
|
||||
expect(rowNames(container)).toEqual(['Bravo', 'Charlie', 'Alpha']);
|
||||
|
||||
// Alphabetical.
|
||||
await selectSort(user, container, 'Alphabetical');
|
||||
await waitFor(() => expect(rowNames(container)).toEqual(['Alpha', 'Bravo', 'Charlie']));
|
||||
});
|
||||
|
||||
it('jumps directly to a page via the numbered pager', async () => {
|
||||
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
|
||||
renderWithProviders(<ThemeStoreSection />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await screen.findByText('Theme 01');
|
||||
await user.click(screen.getByRole('button', { name: '3' }));
|
||||
|
||||
// Page 3 holds items 25–30; page 1 is gone and page 3 is marked current.
|
||||
expect(screen.getByText('Theme 25')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,55 +1,39 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
|
||||
import { AlertTriangle, Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import CoverLightbox from '../CoverLightbox';
|
||||
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
|
||||
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import { formatRelativeTime } from '../../utils/format/relativeTime';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
|
||||
import {
|
||||
assetUrl,
|
||||
cdnUrl,
|
||||
fetchRegistry,
|
||||
fetchThemeCss,
|
||||
type RegistryTheme,
|
||||
} from '../../utils/themes/themeRegistry';
|
||||
import { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
|
||||
import { validateThemeCss } from '../../utils/themes/themeInjection';
|
||||
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
||||
|
||||
type ModeFilter = 'all' | 'dark' | 'light';
|
||||
type SortMode = 'newest' | 'name';
|
||||
|
||||
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
|
||||
|
||||
/** Themes shown per page — the catalogue is large enough to paginate. */
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
/** Page numbers for the pager: all of them when there are few, otherwise the
|
||||
* first and last page plus a window around the current one, with gaps. */
|
||||
function pageItemsList(current: number, total: number): (number | 'gap')[] {
|
||||
if (total <= 10) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const out: (number | 'gap')[] = [1];
|
||||
const lo = Math.max(2, current - 2);
|
||||
const hi = Math.min(total - 1, current + 2);
|
||||
if (lo > 2) out.push('gap');
|
||||
for (let p = lo; p <= hi; p++) out.push(p);
|
||||
if (hi < total - 1) out.push('gap');
|
||||
out.push(total);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The community Theme Store: browse the GitHub-hosted registry, filter by name
|
||||
* The community Theme Store: browse the jsDelivr-hosted registry, filter by name
|
||||
* and light/dark, install (fetch + persist + runtime inject), apply, update and
|
||||
* uninstall. Built-in themes are not in the registry, so they never appear here.
|
||||
*/
|
||||
export function ThemeStoreSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const activeTheme = useThemeStore(s => s.theme);
|
||||
const setTheme = useThemeStore(s => s.setTheme);
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const install = useInstalledThemesStore(s => s.install);
|
||||
const animRisk = useThemeAnimationRisk();
|
||||
|
||||
const [themes, setThemes] = useState<RegistryTheme[] | null>(null);
|
||||
@@ -60,7 +44,6 @@ export function ThemeStoreSection() {
|
||||
const [stale, setStale] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<ModeFilter>('all');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
||||
const [page, setPage] = useState(1);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
@@ -82,12 +65,12 @@ export function ThemeStoreSection() {
|
||||
.finally(() => { setLoading(false); setRefreshing(false); });
|
||||
};
|
||||
|
||||
// GitHub raw caches thumbnails only briefly, but the webview can still hold an
|
||||
// old copy; tie a cache-buster to the registry's generatedAt — it changes on
|
||||
// every themes push — so refreshed thumbnails show up after a registry refresh
|
||||
// instead of being stuck on the old image.
|
||||
// Thumbnails live at a stable CDN path, so the webview caches them hard
|
||||
// (jsDelivr sends max-age 7d). Tie a cache-buster to the registry's
|
||||
// generatedAt — it changes on every themes push — so refreshed thumbnails
|
||||
// show up after a registry refresh instead of being stuck on the old image.
|
||||
const thumbUrl = (rel: string) =>
|
||||
generatedAt ? `${assetUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : assetUrl(rel);
|
||||
generatedAt ? `${cdnUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : cdnUrl(rel);
|
||||
|
||||
useEffect(() => { load(false); }, []);
|
||||
|
||||
@@ -100,7 +83,7 @@ export function ThemeStoreSection() {
|
||||
const filtered = useMemo(() => {
|
||||
if (!themes) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = themes.filter(th => {
|
||||
return themes.filter(th => {
|
||||
if (mode !== 'all' && th.mode !== mode) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
@@ -109,17 +92,12 @@ export function ThemeStoreSection() {
|
||||
th.description.toLowerCase().includes(q) ||
|
||||
(th.tags || []).some(tag => tag.includes(q))
|
||||
);
|
||||
});
|
||||
// Name is the stable tie-breaker — keeps ordering deterministic when many
|
||||
// themes share the same last-modified date.
|
||||
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
|
||||
if (sortMode === 'name') return matched.sort(byName);
|
||||
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
|
||||
}, [themes, query, mode, sortMode]);
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [themes, query, mode]);
|
||||
|
||||
// A changed filter can shrink the result set below the current page; reset to
|
||||
// the first page whenever the query or mode filter changes.
|
||||
useEffect(() => { setPage(1); }, [query, mode, sortMode]);
|
||||
useEffect(() => { setPage(1); }, [query, mode]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
// Clamp defensively so a stale `page` (e.g. after the registry shrank) never
|
||||
@@ -136,9 +114,30 @@ export function ThemeStoreSection() {
|
||||
const handleInstall = async (th: RegistryTheme) => {
|
||||
setBusyId(th.id);
|
||||
setFailedId(null);
|
||||
const result = await installThemeFromRegistry(th);
|
||||
if (result !== 'ok') setFailedId(th.id);
|
||||
setBusyId(null);
|
||||
try {
|
||||
const css = await fetchThemeCss(th.css);
|
||||
// Don't persist CSS that won't inject — otherwise the theme would show as
|
||||
// installed/active but render nothing. Validate before storing.
|
||||
if (validateThemeCss(css, th.id) == null) {
|
||||
setFailedId(th.id);
|
||||
return;
|
||||
}
|
||||
install({
|
||||
id: th.id,
|
||||
name: th.name,
|
||||
author: th.author,
|
||||
version: th.version,
|
||||
description: th.description,
|
||||
mode: th.mode,
|
||||
tags: th.tags,
|
||||
css,
|
||||
installedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
setFailedId(th.id);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -148,11 +147,6 @@ export function ThemeStoreSection() {
|
||||
{ key: 'light', label: t('settings.themeStoreModeLight') },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'newest', label: t('settings.themeStoreSortNewest') },
|
||||
{ value: 'name', label: t('settings.themeStoreSortName') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
{/* Submit-your-own-theme hint */}
|
||||
@@ -169,7 +163,7 @@ export function ThemeStoreSection() {
|
||||
|
||||
{/* Network disclosure — the store reaches external services. */}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.themeStoreNetworkNotice')}{' '}{t('settings.themeStoreStatsNotice')}
|
||||
{t('settings.themeStoreNetworkNotice')}
|
||||
</p>
|
||||
|
||||
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
|
||||
@@ -185,23 +179,6 @@ export function ThemeStoreSection() {
|
||||
aria-label={t('settings.themeStoreSearchPlaceholder')}
|
||||
style={{ flex: '1 1 180px', minWidth: 140 }}
|
||||
/>
|
||||
<CustomSelect
|
||||
value={sortMode}
|
||||
options={sortOptions}
|
||||
onChange={v => setSortMode(v as SortMode)}
|
||||
style={{
|
||||
width: 170,
|
||||
flexShrink: 0,
|
||||
alignSelf: 'stretch',
|
||||
boxSizing: 'border-box',
|
||||
padding: '0 var(--space-4)',
|
||||
background: 'var(--input-bg)',
|
||||
border: '1px solid var(--input-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4 }} role="group" aria-label={t('settings.themeStoreFilterMode')}>
|
||||
{modeBtns.map(b => (
|
||||
<button
|
||||
@@ -271,7 +248,7 @@ export function ThemeStoreSection() {
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{pageItems.map((th, idx) => {
|
||||
{pageItems.map(th => {
|
||||
const inst = installedMap.get(th.id);
|
||||
const isInstalled = !!inst;
|
||||
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
|
||||
@@ -283,13 +260,11 @@ export function ThemeStoreSection() {
|
||||
className="theme-store-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 14,
|
||||
padding: 12,
|
||||
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
|
||||
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--radius-md, 10px)',
|
||||
// Subtle zebra striping so adjacent rows read as distinct boxes.
|
||||
background: idx % 2 === 1 ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -320,20 +295,26 @@ export function ThemeStoreSection() {
|
||||
<Check size={12} /> {t('settings.themeStoreActive')}
|
||||
</span>
|
||||
)}
|
||||
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
|
||||
{animRisk && th.animated && (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t('settings.themeAnimationWarning')}
|
||||
data-tooltip={t('settings.themeAnimationWarning')}
|
||||
data-tooltip-pos="top"
|
||||
style={{ display: 'inline-flex', color: 'var(--warning)' }}
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.themeStoreByAuthor', { author: th.author })}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4 }}>
|
||||
{th.description}
|
||||
</div>
|
||||
{th.updatedAt && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 8 }}>
|
||||
{t('settings.themeStoreLastChanged')}: {formatRelativeTime(th.updatedAt, i18n.language)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
|
||||
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
{!isInstalled && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -386,11 +367,7 @@ export function ThemeStoreSection() {
|
||||
)}
|
||||
|
||||
{!loading && !error && pageCount > 1 && (
|
||||
<div
|
||||
role="navigation"
|
||||
aria-label={t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap', gap: 6, marginTop: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginTop: 16 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 10px' }}
|
||||
@@ -402,21 +379,9 @@ export function ThemeStoreSection() {
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
{pageItemsList(safePage, pageCount).map((it, i) =>
|
||||
it === 'gap' ? (
|
||||
<span key={`gap-${i}`} style={{ color: 'var(--text-muted)', padding: '0 2px' }}>…</span>
|
||||
) : (
|
||||
<button
|
||||
key={it}
|
||||
className={`btn ${it === safePage ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12.5, padding: '4px 10px', minWidth: 34 }}
|
||||
aria-current={it === safePage ? 'page' : undefined}
|
||||
onClick={() => goToPage(it)}
|
||||
>
|
||||
{it}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<span role="status" style={{ fontSize: 12.5, color: 'var(--text-muted)', minWidth: 96, textAlign: 'center' }}>
|
||||
{t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 10px' }}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AudioLines, ChevronRight, HardDriveDownload, PlayCircle, Settings, Spar
|
||||
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import WhatsNewBanner from '../WhatsNewBanner';
|
||||
import ThemeUpdateBanner from '../ThemeUpdateBanner';
|
||||
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
|
||||
import SidebarLibraryPicker from './SidebarLibraryPicker';
|
||||
import SidebarActiveJobs from './SidebarActiveJobs';
|
||||
@@ -217,9 +216,6 @@ export default function SidebarNavBody(props: Props) {
|
||||
{/* What's New banner — only visible while the current release hasn't been seen. */}
|
||||
<WhatsNewBanner collapsed={isCollapsed} />
|
||||
|
||||
{/* Theme-update notice — only visible while an installed theme has an update. */}
|
||||
<ThemeUpdateBanner collapsed={isCollapsed} />
|
||||
|
||||
{/* Now Playing — pinned at the bottom unless the user moved it to the top. */}
|
||||
{!nowPlayingAtTop && nowPlayingLink}
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { Activity, Network, ScrollText, SlidersHorizontal, Wrench, X } from 'lucide-react';
|
||||
import { Activity, ScrollText, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import SidebarPerfProbeMonitorTab from './perfProbe/SidebarPerfProbeMonitorTab';
|
||||
import SidebarPerfProbeTogglesTab from './perfProbe/SidebarPerfProbeTogglesTab';
|
||||
import SidebarPerfProbeTuningTab from './perfProbe/SidebarPerfProbeTuningTab';
|
||||
import SidebarPerfProbeLogsTab from './perfProbe/SidebarPerfProbeLogsTab';
|
||||
import SidebarPerfProbeConnectionsTab from './perfProbe/SidebarPerfProbeConnectionsTab';
|
||||
import { resetPerfProbeFlags, type PerfProbeFlags } from '../../utils/perf/perfFlags';
|
||||
import { clearPerfLiveOverlayPins } from '../../utils/perf/perfOverlayPins';
|
||||
import { resetPerfOverlayAppearance } from '../../utils/perf/perfOverlayAppearance';
|
||||
import { resetPerfOverlayMode } from '../../utils/perf/perfOverlayMode';
|
||||
|
||||
type TabId = 'monitor' | 'connections' | 'toggles' | 'tuning' | 'logs';
|
||||
type TabId = 'monitor' | 'toggles' | 'logs';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -53,7 +51,7 @@ export default function SidebarPerfProbeModal({
|
||||
onClick={() => onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="psylab-title"
|
||||
aria-labelledby="perf-probe-title"
|
||||
>
|
||||
<div
|
||||
className="modal-content sidebar-perf-modal"
|
||||
@@ -64,13 +62,13 @@ export default function SidebarPerfProbeModal({
|
||||
</button>
|
||||
|
||||
<header className="sidebar-perf-modal__header">
|
||||
<h3 id="psylab-title" className="modal-title">PsyLab</h3>
|
||||
<h3 id="perf-probe-title" className="modal-title">Performance Probe</h3>
|
||||
<p className="sidebar-perf-modal__hint">
|
||||
Live metrics, server connections, runtime tuning, and diagnostic disable toggles.
|
||||
Live metrics with optional on-screen overlays, plus diagnostic disable toggles.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="PsyLab sections">
|
||||
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="Performance probe sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -81,16 +79,6 @@ export default function SidebarPerfProbeModal({
|
||||
<Activity size={15} />
|
||||
Monitor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'connections'}
|
||||
className={`sidebar-perf-modal__tab${tab === 'connections' ? ' sidebar-perf-modal__tab--active' : ''}`}
|
||||
onClick={() => setTab('connections')}
|
||||
>
|
||||
<Network size={15} />
|
||||
Connections
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -101,16 +89,6 @@ export default function SidebarPerfProbeModal({
|
||||
<SlidersHorizontal size={15} />
|
||||
Toggles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'tuning'}
|
||||
className={`sidebar-perf-modal__tab${tab === 'tuning' ? ' sidebar-perf-modal__tab--active' : ''}`}
|
||||
onClick={() => setTab('tuning')}
|
||||
>
|
||||
<Wrench size={15} />
|
||||
Tuning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -125,8 +103,6 @@ export default function SidebarPerfProbeModal({
|
||||
|
||||
<div className={`sidebar-perf-modal__body${tab === 'logs' ? ' sidebar-perf-modal__body--logs' : ''}`}>
|
||||
{tab === 'monitor' && <SidebarPerfProbeMonitorTab />}
|
||||
{tab === 'connections' && <SidebarPerfProbeConnectionsTab />}
|
||||
{tab === 'tuning' && <SidebarPerfProbeTuningTab />}
|
||||
{tab === 'toggles' && (
|
||||
<SidebarPerfProbeTogglesTab
|
||||
perfFlags={perfFlags}
|
||||
|
||||
@@ -10,7 +10,7 @@ const COVER_THREADS_MIN = 1;
|
||||
const COVER_THREADS_MAX = 16;
|
||||
|
||||
/**
|
||||
* PsyLab tuning knob for cover backfill concurrency (download + encode pools
|
||||
* Perf-probe-only knob for cover backfill concurrency (download + encode pools
|
||||
* move together). Deliberately not surfaced in app Settings — it is a live
|
||||
* diagnostics/experiment control. The value is process-local and resets to the
|
||||
* backend default on app restart.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface PerfProbeDetailRow {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: PerfProbeDetailRow[];
|
||||
}
|
||||
|
||||
export default function PerfProbeDetailList({ rows }: Props) {
|
||||
return (
|
||||
<dl className="perf-server-dl">
|
||||
{rows.map(row => (
|
||||
<div key={row.label} className="perf-server-dl__row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,6 @@ interface SectionProps {
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
layout?: 'grid' | 'stack';
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -80,7 +79,6 @@ export function PerfProbeMetricSection({
|
||||
hint,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
layout = 'grid',
|
||||
onOpenChange,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
@@ -94,9 +92,7 @@ export function PerfProbeMetricSection({
|
||||
<span>{title}</span>
|
||||
{hint && <span className="perf-metric-section__hint">{hint}</span>}
|
||||
</summary>
|
||||
<div className={layout === 'stack' ? 'perf-metric-section__body' : 'perf-metric-section__grid'}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="perf-metric-section__grid">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PerfProbeBadgeTone = 'ok' | 'warn' | 'error' | 'neutral' | 'muted';
|
||||
|
||||
interface Props {
|
||||
tone: PerfProbeBadgeTone;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PerfProbeStatusBadge({ tone, children }: Props) {
|
||||
return (
|
||||
<span className={`perf-status-badge perf-status-badge--${tone}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { usePlayerStore } from '../../../store/playerStore';
|
||||
import { useConnectionStatus } from '../../../hooks/useConnectionStatus';
|
||||
import { useNavidromeAdminRole } from '../../../hooks/useNavidromeAdminRole';
|
||||
import { serverListDisplayLabel } from '../../../utils/server/serverDisplayName';
|
||||
import { findServerByIdOrIndexKey } from '../../../utils/server/serverLookup';
|
||||
import { PerfProbeMetricSection } from './PerfProbeMetricCard';
|
||||
import PerfProbeDetailList from './PerfProbeDetailList';
|
||||
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from './PerfProbeStatusBadge';
|
||||
import SidebarPerfProbeServerSection from './SidebarPerfProbeServerSection';
|
||||
|
||||
function connectionStatusBadge(status: string): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (status) {
|
||||
case 'connected': return { tone: 'ok', label: 'Connected' };
|
||||
case 'disconnected': return { tone: 'error', label: 'Disconnected' };
|
||||
case 'checking': return { tone: 'warn', label: 'Checking…' };
|
||||
default: return { tone: 'muted', label: status };
|
||||
}
|
||||
}
|
||||
|
||||
function sessionBadge(loggedIn: boolean): { tone: PerfProbeBadgeTone; label: string } {
|
||||
return loggedIn
|
||||
? { tone: 'ok', label: 'Logged in' }
|
||||
: { tone: 'muted', label: 'Not logged in' };
|
||||
}
|
||||
|
||||
function adminRoleBadge(role: ReturnType<typeof useNavidromeAdminRole>): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (role) {
|
||||
case 'admin': return { tone: 'ok', label: 'Admin' };
|
||||
case 'user': return { tone: 'neutral', label: 'Standard user' };
|
||||
case 'checking':
|
||||
case 'idle': return { tone: 'warn', label: 'Checking…' };
|
||||
case 'error': return { tone: 'error', label: 'Could not verify' };
|
||||
case 'na':
|
||||
default: return { tone: 'muted', label: 'N/A (not Navidrome)' };
|
||||
}
|
||||
}
|
||||
|
||||
function endpointBadge(isLan: boolean): { tone: PerfProbeBadgeTone; label: string } {
|
||||
return isLan
|
||||
? { tone: 'ok', label: 'LAN' }
|
||||
: { tone: 'neutral', label: 'Public / remote' };
|
||||
}
|
||||
|
||||
export default function SidebarPerfProbeConnectionsTab() {
|
||||
const { status, isLan, serverName } = useConnectionStatus();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const connectUrl = useAuthStore(s => s.getBaseUrl());
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const adminRole = useNavidromeAdminRole();
|
||||
|
||||
const queueServer = queueServerId ? findServerByIdOrIndexKey(queueServerId) : undefined;
|
||||
const queueDiffersFromActive = Boolean(
|
||||
queueServerId && activeServerId && queueServerId !== activeServerId,
|
||||
);
|
||||
|
||||
const connBadge = connectionStatusBadge(status);
|
||||
const sessBadge = sessionBadge(isLoggedIn);
|
||||
const roleBadge = adminRoleBadge(adminRole);
|
||||
|
||||
return (
|
||||
<div className="perf-monitor">
|
||||
<div className="perf-conn-summary" aria-label="Connection overview">
|
||||
<div className="perf-conn-summary__item">
|
||||
<span className="perf-conn-summary__label">Link</span>
|
||||
<PerfProbeStatusBadge tone={connBadge.tone}>{connBadge.label}</PerfProbeStatusBadge>
|
||||
</div>
|
||||
<div className="perf-conn-summary__item">
|
||||
<span className="perf-conn-summary__label">Session</span>
|
||||
<PerfProbeStatusBadge tone={sessBadge.tone}>{sessBadge.label}</PerfProbeStatusBadge>
|
||||
</div>
|
||||
{isLoggedIn && (
|
||||
<div className="perf-conn-summary__item">
|
||||
<span className="perf-conn-summary__label">Role</span>
|
||||
<PerfProbeStatusBadge tone={roleBadge.tone}>{roleBadge.label}</PerfProbeStatusBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PerfProbeMetricSection title="Connection" defaultOpen layout="stack">
|
||||
<PerfProbeDetailList
|
||||
rows={[
|
||||
{
|
||||
label: 'Status',
|
||||
value: <PerfProbeStatusBadge tone={connBadge.tone}>{connBadge.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
{
|
||||
label: 'Session',
|
||||
value: <PerfProbeStatusBadge tone={sessBadge.tone}>{sessBadge.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
...(isLoggedIn
|
||||
? [{
|
||||
label: 'Navidrome role',
|
||||
value: <PerfProbeStatusBadge tone={roleBadge.tone}>{roleBadge.label}</PerfProbeStatusBadge>,
|
||||
}]
|
||||
: []),
|
||||
...(serverName ? [{ label: 'Browse label', value: serverName }] : []),
|
||||
...(connectUrl ? [{ label: 'Connect URL', value: <code className="perf-server-dl__code">{connectUrl}</code> }] : []),
|
||||
...(status === 'connected'
|
||||
? [{
|
||||
label: 'Endpoint',
|
||||
value: (
|
||||
<PerfProbeStatusBadge tone={endpointBadge(isLan).tone}>
|
||||
{endpointBadge(isLan).label}
|
||||
</PerfProbeStatusBadge>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</PerfProbeMetricSection>
|
||||
|
||||
<SidebarPerfProbeServerSection adminRole={adminRole} />
|
||||
|
||||
{queueDiffersFromActive && queueServer && (
|
||||
<PerfProbeMetricSection title="Queue playback server" defaultOpen={false} layout="stack">
|
||||
<PerfProbeDetailList
|
||||
rows={[
|
||||
{ label: 'Name', value: serverListDisplayLabel(queueServer, servers) },
|
||||
{ label: 'Scope key', value: <code className="perf-server-dl__code">{queueServerId}</code> },
|
||||
]}
|
||||
/>
|
||||
</PerfProbeMetricSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,11 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { Copy, Download, Pause, Play, Trash2 } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Pause, Play, Trash2 } from 'lucide-react';
|
||||
import { getLoggingMode, tailRuntimeLogs, type RuntimeLogLine } from '../../../api/runtimeLogs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import type { LoggingMode } from '../../../store/authStoreTypes';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import { filterLogLines } from '../../../utils/perf/filterLogLines';
|
||||
import { sanitizeLogLine } from '../../../utils/perf/sanitizeLogLine';
|
||||
|
||||
function formatLogLinesText(lines: RuntimeLogLine[]): string {
|
||||
return lines.map(line => line.text).join('\n');
|
||||
}
|
||||
|
||||
const POLL_MS = 750;
|
||||
const BOTTOM_EPSILON = 24;
|
||||
@@ -56,10 +40,6 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
const [lineCap, setLineCap] = useState(1000);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [overflowed, setOverflowed] = useState(false);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'ok' | 'error'>('idle');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const lastSeqRef = useRef<number | null>(null);
|
||||
const pausedRef = useRef(paused);
|
||||
@@ -96,13 +76,7 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
if (!cancelled && tail.lines.length > 0) {
|
||||
lastSeqRef.current = tail.lastSeq;
|
||||
setLines(prev => {
|
||||
const next = [
|
||||
...prev,
|
||||
...tail.lines.map(line => ({
|
||||
...line,
|
||||
text: sanitizeLogLine(line.text),
|
||||
})),
|
||||
];
|
||||
const next = [...prev, ...tail.lines];
|
||||
// Only trim from the top while following; otherwise keep history
|
||||
// under the reader's viewport up to the hard ceiling.
|
||||
const cap = followRef.current ? lineCapRef.current : MAX_BUFFER;
|
||||
@@ -182,80 +156,6 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
setOverflowed(false);
|
||||
};
|
||||
|
||||
const selectedLogText = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
const sel = window.getSelection();
|
||||
if (!el || !sel || sel.isCollapsed || sel.rangeCount === 0) return '';
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.commonAncestorContainer)) return '';
|
||||
return sel.toString();
|
||||
}, []);
|
||||
|
||||
const copyText = async (text: string, feedback = true) => {
|
||||
if (!text) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (feedback) setCopyState('ok');
|
||||
return true;
|
||||
} catch {
|
||||
if (feedback) setCopyState('error');
|
||||
return false;
|
||||
} finally {
|
||||
if (feedback) window.setTimeout(() => setCopyState('idle'), 1600);
|
||||
}
|
||||
};
|
||||
|
||||
const copyAllShown = () => copyText(formatLogLinesText(visible));
|
||||
|
||||
const copySelection = () => copyText(selectedLogText(), false);
|
||||
|
||||
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current?.contains(e.target as Node)) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeContextMenu();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
const onLogContextMenu = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selected = selectedLogText().trim();
|
||||
if (!selected) return;
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const exportVisible = async () => {
|
||||
if (visible.length === 0 || exporting) return;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const selected = await saveDialog({
|
||||
defaultPath: `psysonic-psylab-logs-${stamp}.log`,
|
||||
filters: [{ name: 'Log files', extensions: ['log', 'txt'] }],
|
||||
title: 'Export shown log lines',
|
||||
});
|
||||
if (!selected || Array.isArray(selected)) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(`${formatLogLinesText(visible)}\n`);
|
||||
await writeFile(selected, bytes);
|
||||
} catch {
|
||||
/* user cancelled or write failed */
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="perf-logs">
|
||||
<div className="perf-logs__controls">
|
||||
@@ -289,26 +189,6 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
<Trash2 size={14} />
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="perf-logs__btn"
|
||||
onClick={() => void copyAllShown()}
|
||||
disabled={visible.length === 0}
|
||||
title="Copy all lines currently shown in the log view"
|
||||
>
|
||||
<Copy size={14} />
|
||||
{copyState === 'ok' ? 'Copied' : copyState === 'error' ? 'Copy failed' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="perf-logs__btn"
|
||||
onClick={() => void exportVisible()}
|
||||
disabled={visible.length === 0 || exporting}
|
||||
title="Export shown lines to a file"
|
||||
>
|
||||
<Download size={14} />
|
||||
{exporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -324,10 +204,8 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
className="perf-logs__view"
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
onContextMenu={onLogContextMenu}
|
||||
role="log"
|
||||
aria-live="off"
|
||||
data-selectable
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<div className="perf-logs__empty">
|
||||
@@ -361,28 +239,6 @@ export default function SidebarPerfProbeLogsTab() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu && createPortal(
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="context-menu perf-logs__context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="context-menu-item perf-logs__context-item"
|
||||
onClick={() => {
|
||||
void copySelection();
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
Copy
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import PerfProbeMetricCard, { PerfProbeMetricSection } from './PerfProbeMetricCa
|
||||
import PerfOverlayAppearanceControls from './PerfOverlayAppearanceControls';
|
||||
import PerfOverlayModeControls from './PerfOverlayModeControls';
|
||||
import PerfLivePollControls from './PerfLivePollControls';
|
||||
import PerfCoverThreadsControl from './PerfCoverThreadsControl';
|
||||
|
||||
function memoryBarPct(rssKb: number, maxKb: number): number {
|
||||
if (maxKb <= 0) return 0;
|
||||
@@ -60,6 +61,7 @@ export default function SidebarPerfProbeMonitorTab() {
|
||||
<PerfOverlayModeControls />
|
||||
<PerfOverlayAppearanceControls />
|
||||
<PerfLivePollControls />
|
||||
<PerfCoverThreadsControl />
|
||||
<PerfProbeMetricSection title="Pipeline overlays" hint="Rust / UI queues">
|
||||
<PerfProbeMetricCard
|
||||
label="FPS"
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import type { NavidromeAdminRole } from '../../../hooks/useNavidromeAdminRole';
|
||||
import { isNavidromeServer, formatServerSoftware } from '../../../utils/server/subsonicServerIdentity';
|
||||
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS } from '../../../serverCapabilities/catalog';
|
||||
import {
|
||||
isFeatureActiveForServer,
|
||||
resolveCallRoutesForServer,
|
||||
resolveFeatureForServer,
|
||||
} from '../../../serverCapabilities/storeView';
|
||||
import type { CapabilityStatus, FeatureTrust, ResolvedCapability } from '../../../serverCapabilities/types';
|
||||
import { PerfProbeMetricSection } from './PerfProbeMetricCard';
|
||||
import PerfProbeDetailList, { type PerfProbeDetailRow } from './PerfProbeDetailList';
|
||||
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from './PerfProbeStatusBadge';
|
||||
|
||||
function detectionBadge(status: CapabilityStatus): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (status) {
|
||||
case 'present': return { tone: 'ok', label: 'Detected' };
|
||||
case 'absent': return { tone: 'muted', label: 'Not detected' };
|
||||
case 'probing': return { tone: 'warn', label: 'Checking…' };
|
||||
case 'error': return { tone: 'error', label: 'Probe failed' };
|
||||
case 'unknown': return { tone: 'muted', label: 'Not probed yet' };
|
||||
default: return { tone: 'muted', label: 'N/A' };
|
||||
}
|
||||
}
|
||||
|
||||
function strategyLabel(strategyId: string | null): string {
|
||||
switch (strategyId) {
|
||||
case 'opensubsonic.sonicSimilarity': return 'sonicSimilarity (OpenSubsonic)';
|
||||
case 'subsonic.getSimilarSongs': return 'getSimilarSongs (legacy)';
|
||||
default: return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function trustBadge(trust: FeatureTrust | null): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (trust) {
|
||||
case 'high': return { tone: 'ok', label: 'high' };
|
||||
case 'low': return { tone: 'warn', label: 'heuristic' };
|
||||
default: return { tone: 'muted', label: '—' };
|
||||
}
|
||||
}
|
||||
|
||||
function adminRoleBadge(role: NavidromeAdminRole): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (role) {
|
||||
case 'admin': return { tone: 'ok', label: 'Admin' };
|
||||
case 'user': return { tone: 'neutral', label: 'Standard user' };
|
||||
case 'checking':
|
||||
case 'idle': return { tone: 'warn', label: 'Checking…' };
|
||||
case 'error': return { tone: 'error', label: 'Could not verify' };
|
||||
case 'na':
|
||||
default: return { tone: 'muted', label: 'N/A' };
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
adminRole?: NavidromeAdminRole;
|
||||
}
|
||||
|
||||
export default function SidebarPerfProbeServerSection({ adminRole = 'na' }: Props) {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const server = useAuthStore(s => s.servers.find(srv => srv.id === s.activeServerId));
|
||||
const identity = useAuthStore(s =>
|
||||
activeServerId ? s.subsonicServerIdentityByServer[activeServerId] : undefined,
|
||||
);
|
||||
// Subscribe to the probe maps so the resolver-derived rows re-render on probe updates.
|
||||
useAuthStore(s => (activeServerId ? s.audiomusePluginProbeByServer[activeServerId] : undefined));
|
||||
useAuthStore(s => (activeServerId ? s.instantMixProbeByServer[activeServerId] : undefined));
|
||||
useAuthStore(s => (activeServerId ? s.audiomuseNavidromeByServer[activeServerId] : undefined));
|
||||
|
||||
if (!server) {
|
||||
return (
|
||||
<PerfProbeMetricSection title="Active server" defaultOpen layout="stack">
|
||||
<div className="perf-monitor-empty perf-monitor-empty--inline">
|
||||
No server configured.
|
||||
</div>
|
||||
</PerfProbeMetricSection>
|
||||
);
|
||||
}
|
||||
|
||||
const navidrome = isNavidromeServer(identity);
|
||||
const role = adminRoleBadge(adminRole);
|
||||
const resolved: ResolvedCapability | null = activeServerId
|
||||
? resolveFeatureForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)
|
||||
: null;
|
||||
const audiomuseActive = activeServerId
|
||||
? isFeatureActiveForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)
|
||||
: false;
|
||||
const routes = activeServerId
|
||||
? resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS)
|
||||
: [];
|
||||
|
||||
const rows: PerfProbeDetailRow[] = [
|
||||
{ label: 'Name', value: server.name || server.url },
|
||||
{ label: 'Profile URL', value: <code className="perf-server-dl__code">{server.url}</code> },
|
||||
{ label: 'Subsonic server', value: formatServerSoftware(identity) ?? 'Unknown' },
|
||||
{
|
||||
label: 'OpenSubsonic',
|
||||
value: identity?.openSubsonic
|
||||
? <PerfProbeStatusBadge tone="ok">yes</PerfProbeStatusBadge>
|
||||
: identity
|
||||
? <PerfProbeStatusBadge tone="muted">no</PerfProbeStatusBadge>
|
||||
: '—',
|
||||
},
|
||||
];
|
||||
|
||||
if (navidrome) {
|
||||
rows.push({
|
||||
label: 'Navidrome role',
|
||||
value: <PerfProbeStatusBadge tone={role.tone}>{role.label}</PerfProbeStatusBadge>,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolved && resolved.strategyId !== null && resolved.status !== 'ineligible') {
|
||||
const detect = detectionBadge(resolved.status);
|
||||
const trust = trustBadge(resolved.trust);
|
||||
rows.push(
|
||||
{
|
||||
label: 'AudioMuse Instant Mix',
|
||||
value: <PerfProbeStatusBadge tone={detect.tone}>{detect.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
{ label: 'Provider', value: <code className="perf-server-dl__code">{strategyLabel(resolved.strategyId)}</code> },
|
||||
{
|
||||
label: 'Detection trust',
|
||||
value: <PerfProbeStatusBadge tone={trust.tone}>{trust.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
{
|
||||
label: 'Mode',
|
||||
value: audiomuseActive
|
||||
? (
|
||||
<PerfProbeStatusBadge tone="ok">
|
||||
{resolved.activation === 'auto' ? 'active (auto)' : 'enabled in Settings'}
|
||||
</PerfProbeStatusBadge>
|
||||
)
|
||||
: <PerfProbeStatusBadge tone="muted">off</PerfProbeStatusBadge>,
|
||||
},
|
||||
);
|
||||
if (routes.length > 0) {
|
||||
rows.push({
|
||||
label: 'Call route',
|
||||
value: <code className="perf-server-dl__code">{routes.map(r => r.endpoint).join(' → ')}</code>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PerfProbeMetricSection title="Active server" defaultOpen layout="stack">
|
||||
<PerfProbeDetailList rows={rows} />
|
||||
</PerfProbeMetricSection>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import PerfCoverThreadsControl from './PerfCoverThreadsControl';
|
||||
|
||||
export default function SidebarPerfProbeTuningTab() {
|
||||
return (
|
||||
<div className="perf-tuning">
|
||||
<p className="sidebar-perf-modal__hint perf-tuning__hint">
|
||||
Live runtime knobs for experiments — not persisted in Settings. Values reset on restart unless noted.
|
||||
</p>
|
||||
<PerfCoverThreadsControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import { ndListSongs, ndInvalidateSongsCache } from '../../api/navidromeBrowse';
|
||||
import { usePerfProbeFlags } from '../../utils/perf/perfFlags';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||
|
||||
const RANDOM_RAIL_SIZE = 18;
|
||||
const RATED_RAIL_FETCH = 60;
|
||||
@@ -107,7 +105,13 @@ export default function TracksPageChrome({
|
||||
[random, hero],
|
||||
);
|
||||
|
||||
const heroArtistRefs = hero ? resolveTrackArtistRefs(hero) : [];
|
||||
// Split a multi-artist feature into individually clickable links
|
||||
// (OpenSubsonic `artists[]`), falling back to the single flat artist.
|
||||
const heroArtistRefs = hero
|
||||
? (hero.artists && hero.artists.length > 0
|
||||
? hero.artists
|
||||
: [{ id: hero.artistId, name: hero.artist }])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -144,15 +148,16 @@ export default function TracksPageChrome({
|
||||
</span>
|
||||
<h2 className="tracks-hero-title" title={hero.title}>{hero.title}</h2>
|
||||
<p className="tracks-hero-meta">
|
||||
<OpenArtistRefInline
|
||||
refs={heroArtistRefs}
|
||||
fallbackName={hero.artist}
|
||||
onGoArtist={id => navigateToArtist(id)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="track-artist-link"
|
||||
separatorClassName="track-artist-sep"
|
||||
/>
|
||||
{heroArtistRefs.map((a, i) => (
|
||||
<React.Fragment key={a.id ?? a.name ?? i}>
|
||||
{i > 0 && <span className="track-artist-sep"> · </span>}
|
||||
<span
|
||||
className={a.id ? 'track-artist-link' : ''}
|
||||
style={{ cursor: a.id ? 'pointer' : 'default' }}
|
||||
onClick={() => a.id && navigateToArtist(a.id)}
|
||||
>{a.name ?? hero.artist}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{hero.album && (
|
||||
<>
|
||||
<span className="tracks-hero-meta-dot">·</span>
|
||||
|
||||
@@ -154,10 +154,6 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)',
|
||||
'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)',
|
||||
'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)',
|
||||
'Themed startup splash before Vite loads — deferred window show, per-theme logo gradient (PR #1030)',
|
||||
'Artist page: Top Tracks play when album list is empty on the page (PR #1031)',
|
||||
'PsyLab Connections tab with server capability readout, Navidrome admin-role probe, and tab-bar layout fix; server-capability framework with AudioMuse detection via OpenSubsonic sonicSimilarity and sonic Instant Mix routing on Navidrome ≥0.62 (PR #1033)',
|
||||
'Library DB: named slow-write op labels for macOS playback-stall diagnosis (PR #1043)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -361,7 +357,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, free-form community themes (safety floor + state-reactive styling), and per-theme download counts with popularity/newest/name sorting; 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020, #1036)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, and free-form community themes (safety floor + state-reactive styling); 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/** Splash colors aligned with bundled theme semantic tokens (`--bg-app`, etc.). */
|
||||
export type StartupSplashPalette = {
|
||||
bg: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
track: string;
|
||||
/** Sidebar logo gradient start (`--logo-color-start` / `--accent`). */
|
||||
logoStart: string;
|
||||
/** Sidebar logo gradient end (`--logo-color-end` / `--accent-2`). */
|
||||
logoEnd: string;
|
||||
};
|
||||
|
||||
export const BUILTIN_SPLASH_PALETTES: Record<string, StartupSplashPalette> = {
|
||||
mocha: {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
},
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_THEME_IDS = Object.keys(BUILTIN_SPLASH_PALETTES);
|
||||
@@ -8,8 +8,6 @@ import { playListenSessionFinalize } from '../../store/playListenSession';
|
||||
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { endOrbitSession, leaveOrbitSession } from '../../utils/orbit';
|
||||
import {
|
||||
canRunShortcutActionInMiniWindow,
|
||||
executeRuntimeAction,
|
||||
@@ -116,14 +114,12 @@ export function useMediaAndWindowBridge(navigate: NavigateFunction) {
|
||||
flushPlayQueuePosition(),
|
||||
new Promise(r => setTimeout(r, 1500)),
|
||||
]);
|
||||
const role = useOrbitStore.getState().role;
|
||||
if (role === 'host' || role === 'guest') {
|
||||
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
|
||||
await Promise.race([
|
||||
teardown.catch(() => {}),
|
||||
new Promise(r => setTimeout(r, 1500)),
|
||||
]);
|
||||
}
|
||||
// Orbit: do NOT tear the session down on quit. Leave it alive on the
|
||||
// server (and the breadcrumb in place) so the next launch can offer to
|
||||
// reconnect — the host resumes hosting, a guest rejoins. Deliberately
|
||||
// ending a session is the session bar's "End" button; quitting only
|
||||
// suspends it. Orphaned playlists are reaped by the app-start sweep
|
||||
// once the session goes stale.
|
||||
await invoke('exit_app');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
vi.mock('@/api/navidromeAdmin', () => ({
|
||||
ndLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ndLogin } from '@/api/navidromeAdmin';
|
||||
import { useNavidromeAdminRole } from './useNavidromeAdminRole';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
vi.mocked(ndLogin).mockReset();
|
||||
});
|
||||
|
||||
function seedNavidromeServer(): string {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Home',
|
||||
url: 'music.example.com',
|
||||
username: 'tester',
|
||||
password: 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
useAuthStore.getState().setLoggedIn(true);
|
||||
useAuthStore.getState().setSubsonicServerIdentity(id, {
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.62.0',
|
||||
openSubsonic: true,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('useNavidromeAdminRole', () => {
|
||||
it('returns na when not logged in', () => {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Home',
|
||||
url: 'https://music.example.com',
|
||||
username: 'tester',
|
||||
password: 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
useAuthStore.getState().setSubsonicServerIdentity(id, {
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.62.0',
|
||||
openSubsonic: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNavidromeAdminRole());
|
||||
expect(result.current).toBe('na');
|
||||
expect(ndLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns checking until server identity is known', () => {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Home',
|
||||
url: 'https://music.example.com',
|
||||
username: 'tester',
|
||||
password: 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
useAuthStore.getState().setLoggedIn(true);
|
||||
|
||||
const { result } = renderHook(() => useNavidromeAdminRole());
|
||||
expect(result.current).toBe('checking');
|
||||
expect(ndLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns na for non-Navidrome servers', () => {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Ampache',
|
||||
url: 'https://music.example.com',
|
||||
username: 'tester',
|
||||
password: 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
useAuthStore.getState().setLoggedIn(true);
|
||||
useAuthStore.getState().setSubsonicServerIdentity(id, {
|
||||
type: 'ampache',
|
||||
serverVersion: '6.0.0',
|
||||
openSubsonic: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNavidromeAdminRole());
|
||||
expect(result.current).toBe('na');
|
||||
expect(ndLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('probes Navidrome native login and reports admin', async () => {
|
||||
seedNavidromeServer();
|
||||
vi.mocked(ndLogin).mockResolvedValue({ token: 't', userId: '1', isAdmin: true });
|
||||
|
||||
const { result } = renderHook(() => useNavidromeAdminRole());
|
||||
await waitFor(() => expect(result.current).toBe('admin'));
|
||||
expect(ndLogin).toHaveBeenCalledWith('http://music.example.com', 'tester', 'pw');
|
||||
});
|
||||
|
||||
it('probes Navidrome native login and reports standard user', async () => {
|
||||
seedNavidromeServer();
|
||||
vi.mocked(ndLogin).mockResolvedValue({ token: 't', userId: '2', isAdmin: false });
|
||||
|
||||
const { result } = renderHook(() => useNavidromeAdminRole());
|
||||
await waitFor(() => expect(result.current).toBe('user'));
|
||||
});
|
||||
|
||||
it('returns error when native login fails', async () => {
|
||||
seedNavidromeServer();
|
||||
vi.mocked(ndLogin).mockRejectedValue(new Error('denied'));
|
||||
|
||||
const { result } = renderHook(() => useNavidromeAdminRole());
|
||||
await waitFor(() => expect(result.current).toBe('error'));
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ndLogin } from '../api/navidromeAdmin';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { isNavidromeServer } from '../utils/server/subsonicServerIdentity';
|
||||
|
||||
export type NavidromeAdminRole = 'idle' | 'checking' | 'admin' | 'user' | 'na' | 'error';
|
||||
|
||||
function normalizeServerUrl(url: string): string {
|
||||
const withScheme = url.startsWith('http') ? url : `http://${url}`;
|
||||
return withScheme.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes Navidrome native login for the active server to learn whether the
|
||||
* current Subsonic credentials belong to an admin account.
|
||||
*/
|
||||
export function useNavidromeAdminRole(): NavidromeAdminRole {
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const server = useAuthStore(s => s.servers.find(srv => srv.id === s.activeServerId));
|
||||
const identity = useAuthStore(s =>
|
||||
activeServerId ? s.subsonicServerIdentityByServer[activeServerId] : undefined,
|
||||
);
|
||||
const [role, setRole] = useState<NavidromeAdminRole>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !server) {
|
||||
setRole('na');
|
||||
return;
|
||||
}
|
||||
if (!identity) {
|
||||
setRole('checking');
|
||||
return;
|
||||
}
|
||||
if (!isNavidromeServer(identity)) {
|
||||
setRole('na');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setRole('checking');
|
||||
const serverUrl = normalizeServerUrl(server.url);
|
||||
ndLogin(serverUrl, server.username, server.password)
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
setRole(res.isAdmin ? 'admin' : 'user');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRole('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isLoggedIn,
|
||||
activeServerId,
|
||||
server?.id,
|
||||
server?.url,
|
||||
server?.username,
|
||||
server?.password,
|
||||
identity?.type,
|
||||
identity?.serverVersion,
|
||||
]);
|
||||
|
||||
return role;
|
||||
}
|
||||
@@ -18,10 +18,9 @@ vi.mock('../api/subsonicLibrary');
|
||||
vi.mock('../api/bandsintown');
|
||||
vi.mock('../api/lastfm');
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: vi.fn(() => true),
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
||||
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
||||
import { fetchBandsintownEvents } from '../api/bandsintown';
|
||||
@@ -213,53 +212,3 @@ describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography'
|
||||
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D2']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useNowPlayingFetchers — local-playback metadata', () => {
|
||||
// Regression: the metadata gate must never pass the playing track id, or the
|
||||
// guard's `psysonic-local://` skip would blank every Subsonic card whenever
|
||||
// the track plays from hot-cache / offline bytes. Guard is called with the
|
||||
// server id only.
|
||||
// Keep the shared guard mock at its permissive default after the behaviour
|
||||
// case below swaps in a trackId-sensitive implementation.
|
||||
afterEach(() => {
|
||||
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(() => true);
|
||||
});
|
||||
|
||||
it('queries the network guard without a trackId', async () => {
|
||||
const guard = vi.mocked(shouldAttemptSubsonicForServer);
|
||||
renderHook(() => useNowPlayingFetchers({ ...baseDeps, songId: 'song-1', albumId: 'al-1', artistId: 'art-1', artistName: 'Artist' }));
|
||||
await waitFor(() => expect(guard).toHaveBeenCalled());
|
||||
for (const call of guard.mock.calls) {
|
||||
expect(call).toHaveLength(1);
|
||||
expect(call[1]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('still loads album / discography / top songs when the playback bytes are local', async () => {
|
||||
// Mirror the real guard: a byte-style call (with a trackId resolving to
|
||||
// psysonic-local://) is blocked, but the metadata gate (server id only) is
|
||||
// allowed. If the hook ever passed the trackId again, every fetch below
|
||||
// would be gated off and the cards would blank — exactly the #1042 bug.
|
||||
// Ids are unique to this test so the shared module caches don't short-circuit it.
|
||||
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(
|
||||
(_serverId, trackId) => trackId === undefined,
|
||||
);
|
||||
vi.mocked(getSongForServer).mockResolvedValue({ id: 'np-song', title: 'Local Track' } as SubsonicSong);
|
||||
vi.mocked(getAlbumForServer).mockResolvedValue(
|
||||
{ album: { id: 'np-al', name: 'Album' } as SubsonicAlbum, songs: [] } as any,
|
||||
);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [{ id: 'np-al' }] } as any);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([{ id: 'np-top' }] as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNowPlayingFetchers({ ...baseDeps, songId: 'np-song', albumId: 'np-al', artistId: 'np-art', artistName: 'NP Artist' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getAlbumForServer).toHaveBeenCalledWith('srv1', 'np-al'));
|
||||
await waitFor(() => expect(getArtistForServer).toHaveBeenCalledWith('srv1', 'np-art'));
|
||||
await waitFor(() => expect(getTopSongsForServer).toHaveBeenCalledWith('srv1', 'NP Artist'));
|
||||
await waitFor(() => expect(result.current.albumData?.album.id).toBe('np-al'));
|
||||
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['np-al']));
|
||||
await waitFor(() => expect(result.current.topSongs.map(s => s.id)).toEqual(['np-top']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getArtistInfoForServer } from '../api/subsonicArtists';
|
||||
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
||||
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '../utils/library/nowPlayingMetadataResolve';
|
||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||
import {
|
||||
lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured,
|
||||
@@ -32,12 +32,7 @@ export interface NowPlayingFetchersDeps {
|
||||
currentTrack: { artist: string; title: string } | null;
|
||||
/** Subsonic server for API calls — must match the playing queue server. */
|
||||
subsonicServerId: string;
|
||||
/**
|
||||
* Caller intent / prerequisites only (e.g. "we have a playback server id").
|
||||
* The network reachability decision — online, server reachable, and no
|
||||
* trackId so local-cache playback still loads metadata — is made here via
|
||||
* `shouldAttemptSubsonicForServer`; callers must not pre-apply that guard.
|
||||
*/
|
||||
/** When false, skip network fetches (e.g. no server id). */
|
||||
fetchEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -86,9 +81,7 @@ export async function prewarmNowPlayingFetchers(
|
||||
} = deps;
|
||||
|
||||
if (!fetchEnabled || !subsonicServerId) return;
|
||||
// Index-first resolvers run whenever there's a server id (offline included) —
|
||||
// each guards its own network fallback. artistInfo below is the one
|
||||
// network-only job, so it keeps the reachability gate.
|
||||
if (!shouldAttemptSubsonicForServer(subsonicServerId, songId)) return;
|
||||
|
||||
const jobs: Array<Promise<unknown>> = [];
|
||||
|
||||
@@ -96,7 +89,7 @@ export async function prewarmNowPlayingFetchers(
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||
if (songMetaCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpSongMeta(subsonicServerId, songId)
|
||||
getSongForServer(subsonicServerId, songId)
|
||||
.then(v => songMetaCache.set(cacheKey, v ?? null))
|
||||
.catch(() => songMetaCache.set(cacheKey, null)),
|
||||
);
|
||||
@@ -105,8 +98,7 @@ export async function prewarmNowPlayingFetchers(
|
||||
|
||||
if (artistId) {
|
||||
const artistKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
// artistInfo (bio/similar) is network-only — keep the reachability gate.
|
||||
if (shouldAttemptSubsonicForServer(subsonicServerId) && artistInfoCache.get(artistKey) === undefined) {
|
||||
if (artistInfoCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
getArtistInfoForServer(subsonicServerId, artistId, {
|
||||
similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined,
|
||||
@@ -117,8 +109,8 @@ export async function prewarmNowPlayingFetchers(
|
||||
}
|
||||
if (discographyCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpDiscography(subsonicServerId, artistId)
|
||||
.then(albums => discographyCache.set(artistKey, albums))
|
||||
getArtistForServer(subsonicServerId, artistId)
|
||||
.then(v => discographyCache.set(artistKey, v.albums))
|
||||
.catch(() => discographyCache.set(artistKey, [])),
|
||||
);
|
||||
}
|
||||
@@ -128,7 +120,7 @@ export async function prewarmNowPlayingFetchers(
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||
if (albumCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpAlbum(subsonicServerId, albumId)
|
||||
getAlbumForServer(subsonicServerId, albumId)
|
||||
.then(v => albumCache.set(cacheKey, v))
|
||||
.catch(() => albumCache.set(cacheKey, null)),
|
||||
);
|
||||
@@ -139,7 +131,7 @@ export async function prewarmNowPlayingFetchers(
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
|
||||
if (topSongsCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpTopSongs(subsonicServerId, artistId, artistName)
|
||||
getTopSongsForServer(subsonicServerId, artistName)
|
||||
.then(v => topSongsCache.set(cacheKey, v))
|
||||
.catch(() => topSongsCache.set(cacheKey, [])),
|
||||
);
|
||||
@@ -208,29 +200,26 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
|
||||
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
// Gate split (PR #1049): index-first resolvers run whenever there's a server id
|
||||
// — they read SQLite even when the server is unreachable (the offline win) and
|
||||
// guard their own network fallback. Only artistInfo (bio/similar, no index) is
|
||||
// network-only, so it keeps the reachability gate.
|
||||
const indexFetchAllowed = fetchEnabled && !!subsonicServerId;
|
||||
const networkOnlyAllowed = indexFetchAllowed && shouldAttemptSubsonicForServer(subsonicServerId);
|
||||
const subsonicFetchAllowed = fetchEnabled
|
||||
&& !!subsonicServerId
|
||||
&& shouldAttemptSubsonicForServer(subsonicServerId, songId);
|
||||
|
||||
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
||||
useEffect(() => {
|
||||
if (!indexFetchAllowed || !songId) { setSongMetaEntry(null); return; }
|
||||
if (!subsonicFetchAllowed || !songId) { setSongMetaEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||
const cached = songMetaCache.get(cacheKey);
|
||||
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
|
||||
setSongMetaEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpSongMeta(subsonicServerId, songId)
|
||||
getSongForServer(subsonicServerId, songId)
|
||||
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } })
|
||||
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, subsonicServerId, songId, connStatus]);
|
||||
}, [subsonicFetchAllowed, subsonicServerId, songId, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!networkOnlyAllowed || !artistId) { setArtistInfoEntry(null); return; }
|
||||
if (!subsonicFetchAllowed || !artistId) { setArtistInfoEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
const cached = artistInfoCache.get(cacheKey);
|
||||
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
|
||||
@@ -240,32 +229,32 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } })
|
||||
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [networkOnlyAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
|
||||
}, [subsonicFetchAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
|
||||
if (!subsonicFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||
const cached = albumCache.get(cacheKey);
|
||||
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
|
||||
setAlbumDataEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpAlbum(subsonicServerId, albumId)
|
||||
getAlbumForServer(subsonicServerId, albumId)
|
||||
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, subsonicServerId, albumId, connStatus]);
|
||||
}, [subsonicFetchAllowed, subsonicServerId, albumId, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
|
||||
if (!subsonicFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
|
||||
const cached = topSongsCache.get(topSongsKey);
|
||||
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
|
||||
setTopSongsEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpTopSongs(subsonicServerId, artistId, artistName)
|
||||
getTopSongsForServer(subsonicServerId, artistName)
|
||||
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, topSongsKey, subsonicServerId, artistId, artistName, connStatus]);
|
||||
}, [subsonicFetchAllowed, topSongsKey, subsonicServerId, artistName, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
|
||||
@@ -282,17 +271,17 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
|
||||
// Discography via getArtist
|
||||
useEffect(() => {
|
||||
if (!indexFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
|
||||
if (!subsonicFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
const cached = discographyCache.get(cacheKey);
|
||||
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
|
||||
setDiscographyEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpDiscography(subsonicServerId, artistId)
|
||||
.then(albums => { if (!cancelled) { discographyCache.set(cacheKey, albums); setDiscographyEntry({ id: artistId, value: albums }); } })
|
||||
getArtistForServer(subsonicServerId, artistId)
|
||||
.then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscographyEntry({ id: artistId, value: v.albums }); } })
|
||||
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
|
||||
}, [subsonicFetchAllowed, subsonicServerId, artistId, connStatus]);
|
||||
|
||||
// Last.fm track info (per-track)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlaybackServerId } from './usePlaybackServerId';
|
||||
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||
|
||||
@@ -64,9 +65,7 @@ export function useNowPlayingPrewarm(): void {
|
||||
lastfmUsername,
|
||||
currentTrack,
|
||||
subsonicServerId: playbackServerId,
|
||||
// No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single
|
||||
// reachability gate, and metadata must warm even when the track's audio
|
||||
// plays from local cache.
|
||||
fetchEnabled: shouldAttemptSubsonicForServer(playbackServerId, currentTrack.id),
|
||||
});
|
||||
|
||||
if (currentTrack.albumId && currentTrack.id) {
|
||||
|
||||
@@ -45,7 +45,7 @@ function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<str
|
||||
);
|
||||
}
|
||||
|
||||
/** Wires Ctrl+Shift+D PsyLab modal and shared live metric polling. */
|
||||
/** Wires Ctrl+Shift+D probe modal and shared live metric polling. */
|
||||
export function useSidebarPerfProbe(): Result {
|
||||
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
|
||||
const livePins = usePerfLiveOverlayPins();
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../utils/themes/themeRegistry', () => ({
|
||||
fetchRegistry: vi.fn(),
|
||||
getCachedRegistry: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { fetchRegistry, type Registry } from '../utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from './useThemeUpdates';
|
||||
|
||||
const fetchRegistryMock = vi.mocked(fetchRegistry);
|
||||
|
||||
function inst(id: string, version: string): InstalledTheme {
|
||||
return { id, name: id, author: 'x', version, description: '', mode: 'dark', css: '', installedAt: 0 };
|
||||
}
|
||||
|
||||
function registry(themes: { id: string; version: string }[]): Registry {
|
||||
return { themes: themes.map(t => ({ id: t.id, name: t.id, version: t.version })) } as unknown as Registry;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
fetchRegistryMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useThemeUpdates', () => {
|
||||
it('lists only installed themes that have a newer registry version', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0'), inst('b', '2.0.0'), inst('c', '1.5.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({
|
||||
registry: registry([
|
||||
{ id: 'a', version: '1.1.0' }, // newer → update
|
||||
{ id: 'b', version: '2.0.0' }, // same → no
|
||||
{ id: 'c', version: '1.4.0' }, // older → no
|
||||
]),
|
||||
stale: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(result.current).toHaveLength(1));
|
||||
expect(result.current[0].id).toBe('a');
|
||||
expect(result.current[0].version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('returns nothing when nothing is outdated', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'a', version: '1.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores registry themes the user has not installed', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'z', version: '9.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('themeUpdateSignature', () => {
|
||||
it('is order-independent and encodes id@version', () => {
|
||||
const a = themeUpdateSignature([{ id: 'b', version: '2.0.0' }, { id: 'a', version: '1.1.0' }]);
|
||||
const b = themeUpdateSignature([{ id: 'a', version: '1.1.0' }, { id: 'b', version: '2.0.0' }]);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('a@1.1.0,b@2.0.0');
|
||||
});
|
||||
|
||||
it('changes when a version bumps so a dismissed notice can reappear', () => {
|
||||
expect(themeUpdateSignature([{ id: 'a', version: '1.1.0' }]))
|
||||
.not.toBe(themeUpdateSignature([{ id: 'a', version: '1.2.0' }]));
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isNewer } from '../utils/componentHelpers/appUpdaterHelpers';
|
||||
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '../utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore } from '../store/installedThemesStore';
|
||||
|
||||
// Refresh the registry from source once per app launch (not just from the
|
||||
// cache). This surfaces newly published themes and updates without the user
|
||||
// having to hit the manual refresh in the Theme Store, and it feeds the
|
||||
// sidebar update notice. Subsequent reads this session use the cache.
|
||||
let sessionRefreshStarted = false;
|
||||
|
||||
/**
|
||||
* Registry entries for installed community themes that have a newer version
|
||||
* available. Returns the full registry theme (css path, version, metadata) so a
|
||||
* caller can update in place. Seeds from the last-cached registry synchronously,
|
||||
* then revalidates (forced on the first call this session). Recomputes when the
|
||||
* installed set changes, so the list shrinks as the user updates themes.
|
||||
*/
|
||||
export function useThemeUpdates(): RegistryTheme[] {
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const [registry, setRegistry] = useState<Registry | null>(() => getCachedRegistry());
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const opts = sessionRefreshStarted ? undefined : { force: true };
|
||||
sessionRefreshStarted = true;
|
||||
fetchRegistry(opts)
|
||||
.then(r => { if (alive) setRegistry(r.registry); })
|
||||
.catch(() => { /* offline: keep whatever the cache gave us */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
|
||||
return registry.themes.filter(rt => {
|
||||
const current = installedVersionById.get(rt.id);
|
||||
return current != null && isNewer(rt.version, current);
|
||||
});
|
||||
}, [registry, installed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of an update set, used to remember a dismissal: the sidebar
|
||||
* notice stays hidden until a new or bumped update changes this string.
|
||||
*/
|
||||
export function themeUpdateSignature(updates: Array<{ id: string; version: string }>): string {
|
||||
return updates.map(u => `${u.id}@${u.version}`).sort().join(',');
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: 'Wieder in deine Orbit-Session?',
|
||||
bodyHost: 'Du hast „{{name}}" gehostet. Dort weitermachen, wo du aufgehört hast?',
|
||||
bodyGuest: '{{host}} hostet „{{name}}". Wieder beitreten?',
|
||||
autoIn: 'Automatisch in {{seconds}}s',
|
||||
rejoin: 'Wieder rein',
|
||||
stayOut: 'Draußen bleiben',
|
||||
reconnecting: 'Trete bei…',
|
||||
failed: 'Wiedereinstieg fehlgeschlagen — die Session ist vielleicht beendet.',
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Gemeinsame Hör-Session starten oder beitreten',
|
||||
launchCreate: 'Session erstellen',
|
||||
|
||||
@@ -52,8 +52,6 @@ export const settings = {
|
||||
serverConnecting: 'Verbinde…',
|
||||
serverConnected: 'Verbunden!',
|
||||
serverFailed: 'Verbindung fehlgeschlagen.',
|
||||
serverAddUnreachable: 'Server nicht erreichbar — Adresse, Netzwerk und das TLS-Zertifikat des Servers prüfen. ({{detail}})',
|
||||
serverAddRejected: 'Der Server hat die Verbindung abgelehnt: {{message}}',
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Für Navidrome entwickelt. Andere Subsonic-kompatible Server (Gonic, Airsonic, …) funktionieren ggf. eingeschränkt, weil Psysonic viele Navidrome-spezifische API-Endpunkte nutzt.',
|
||||
@@ -108,10 +106,6 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Subsonic-Passwort für „{{username}}" eingeben — es wird in den kopierten Magic-String übernommen.',
|
||||
userMgmtMagicStringModalConfirm: 'String kopieren',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseStatusActive: 'Aktiv',
|
||||
audiomuseStatusChecking: 'Wird geprüft…',
|
||||
audiomuseStatusNotDetected: 'Nicht erkannt',
|
||||
audiomuseStatusProbeFailed: 'Prüfung fehlgeschlagen',
|
||||
audiomuseDesc:
|
||||
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
|
||||
audiomuseIssueHint:
|
||||
@@ -157,29 +151,6 @@ export const settings = {
|
||||
cacheUsedHot: 'Belegter Speicher:',
|
||||
hotCacheTrackCount: 'Titel im Cache:',
|
||||
cacheMaxLabel: 'Max. Größe',
|
||||
imageCacheScopeLabel: 'Bereich',
|
||||
imageCacheSubTitle: 'Vorschauen im Browser',
|
||||
coverDiskCacheSubTitle: 'Cover-Cache auf der Festplatte',
|
||||
coverCacheStrategyTitle: 'Cover-Cache',
|
||||
coverCacheStrategyDesc: 'Du kannst für jeden Server eine Strategie wählen: Lazy — Cover bei Bedarf laden; Aggressive — bei Bedarf laden plus zusätzliches Vorladen im Hintergrund.',
|
||||
coverCacheStrategyLabel: 'Strategie',
|
||||
coverCacheStrategyServerLabel: 'Server',
|
||||
coverCacheStrategyProgressLabel: 'Cache',
|
||||
coverCacheStrategyActionsLabel: 'Aktionen',
|
||||
coverCacheStrategyLazy: 'Lazy',
|
||||
coverCacheStrategyLazyDesc: 'Lädt Cover nur, wenn sie benötigt werden.',
|
||||
coverCacheStrategyAggressive: 'Aggressive',
|
||||
coverCacheStrategyAggressiveDesc: 'Lazy-Verhalten plus zusätzliches Vorladen im Hintergrund.',
|
||||
coverCacheStrategyDiskUsage: 'Auf der Festplatte: {{size}}',
|
||||
coverCacheStrategyProgressValue: '{{percent}}% ({{done}}/{{total}}) · {{size}}',
|
||||
coverCacheStrategyClearAction: 'Cover-Cache leeren',
|
||||
coverCacheStrategyClearTitle: 'Cover-Cache leeren?',
|
||||
coverCacheStrategyClearDesc: 'Entfernt alle gecachten Cover-Bilder für {{server}}. Sie werden bei Bedarf neu heruntergeladen.',
|
||||
coverCacheStrategyClearConfirm: 'Leeren',
|
||||
coverCacheStrategyClearCancel: 'Abbrechen',
|
||||
coverCacheStrategyClearSuccess: 'Cover-Cache für diesen Server geleert.',
|
||||
coverCacheStrategyClearError: 'Cover-Cache konnte nicht geleert werden.',
|
||||
coverCacheStrategyServerRemoved: 'entfernter Server',
|
||||
cacheClearBtn: 'Cache leeren',
|
||||
cacheClearWarning: 'Löscht alle gecachten Cover- und Künstlerbilder im Browser-Speicher. Sie werden bei Bedarf neu geladen.',
|
||||
cacheClearConfirm: 'Bild-Cache leeren',
|
||||
@@ -357,8 +328,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Theme-Store',
|
||||
themeStoreSubmitText: 'Eigenes Theme gebaut? Teil es mit der Community — mehr Infos im Themes-Repository.',
|
||||
themeStoreSubmitLink: 'Themes-Repository öffnen',
|
||||
themeStoreNetworkNotice: 'Der Theme-Store lädt Katalog und Vorschauen von GitHub. Es werden keine persönlichen Daten gesendet.',
|
||||
themeStoreStatsNotice: 'Die „Zuletzt geändert“-Daten werden einmal täglich aktualisiert (gegen 04:17 UTC).',
|
||||
themeStoreNetworkNotice: 'Der Theme-Store lädt Katalog und Vorschauen von externen Diensten (jsDelivr-CDN und GitHub). Es werden keine persönlichen Daten gesendet.',
|
||||
themeStoreSearchPlaceholder: 'Themes suchen…',
|
||||
themeStoreFilterMode: 'Nach Modus filtern',
|
||||
themeStoreModeAll: 'Alle',
|
||||
@@ -372,10 +342,7 @@ export const settings = {
|
||||
themeStoreEmpty: 'Keine Themes passen zu deinen Filtern.',
|
||||
themeStoreActive: 'Aktiv',
|
||||
themeStoreEnlarge: 'Vorschau vergrößern',
|
||||
themeStoreSortNewest: 'Neueste',
|
||||
themeStoreSortName: 'Alphabetisch',
|
||||
themeStoreByAuthor: 'von {{author}}',
|
||||
themeStoreLastChanged: 'Zuletzt geändert',
|
||||
themeMigrationNoticeTitle: 'Theme in den Store gezogen',
|
||||
themeMigrationNoticeBody: '{{themes}} ist nicht mehr in der App enthalten. Du kannst es im Theme-Store neu installieren (Einstellungen → Themes) oder ein anderes Theme wählen.',
|
||||
themeMigrationNoticeOpen: 'Theme-Store öffnen',
|
||||
|
||||
@@ -34,8 +34,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Playlists einklappen',
|
||||
more: 'Mehr',
|
||||
feelingLucky: 'Glücks-Mix',
|
||||
|
||||
themeUpdatesTitle: 'Theme-Updates',
|
||||
themeUpdatesTooltip: 'Theme-Updates verfügbar',
|
||||
themeUpdatesDismiss: 'Ausblenden',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: 'Rejoin your Orbit session?',
|
||||
bodyHost: 'You were hosting "{{name}}". Pick the session back up where you left off?',
|
||||
bodyGuest: '{{host}} is hosting "{{name}}". Rejoin the session?',
|
||||
autoIn: 'Auto-rejoin in {{seconds}}s',
|
||||
rejoin: 'Rejoin',
|
||||
stayOut: 'Stay out',
|
||||
reconnecting: 'Rejoining…',
|
||||
failed: "Couldn't rejoin — the session may have ended.",
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Start or join a shared listening session',
|
||||
launchCreate: 'Create a session',
|
||||
|
||||
@@ -52,8 +52,6 @@ export const settings = {
|
||||
serverConnecting: 'Connecting…',
|
||||
serverConnected: 'Connected!',
|
||||
serverFailed: 'Connection failed.',
|
||||
serverAddUnreachable: "Couldn't reach the server — check the address, your network, and the server's TLS certificate. ({{detail}})",
|
||||
serverAddRejected: 'The server rejected the connection: {{message}}',
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Built for Navidrome. Other Subsonic-compatible servers (Gonic, Airsonic, …) may work with reduced functionality, since Psysonic uses many Navidrome-specific API endpoints.',
|
||||
@@ -110,10 +108,6 @@ export const settings = {
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
|
||||
audiomuseStatusActive: 'Active',
|
||||
audiomuseStatusChecking: 'Checking…',
|
||||
audiomuseStatusNotDetected: 'Not detected',
|
||||
audiomuseStatusProbeFailed: 'Probe failed',
|
||||
audiomuseIssueHint:
|
||||
'Instant Mix failed recently — check the Navidrome plugin and AudioMuse API. Similar artists fall back to Last.fm when the server returns none.',
|
||||
connected: 'Connected',
|
||||
@@ -401,8 +395,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Theme Store',
|
||||
themeStoreSubmitText: 'Made your own theme? Share it with the community — more info in the themes repository.',
|
||||
themeStoreSubmitLink: 'Open the themes repository',
|
||||
themeStoreNetworkNotice: 'The Theme Store loads its catalogue and previews from GitHub. No personal data is sent.',
|
||||
themeStoreStatsNotice: 'Last-changed dates refresh once a day (around 04:17 UTC).',
|
||||
themeStoreNetworkNotice: 'The Theme Store loads its catalogue and previews from external services (the jsDelivr CDN and GitHub). No personal data is sent.',
|
||||
themeStoreSearchPlaceholder: 'Search themes…',
|
||||
themeStoreFilterMode: 'Filter by mode',
|
||||
themeStoreModeAll: 'All',
|
||||
@@ -416,10 +409,7 @@ export const settings = {
|
||||
themeStoreEmpty: 'No themes match your filters.',
|
||||
themeStoreActive: 'Active',
|
||||
themeStoreEnlarge: 'Enlarge preview',
|
||||
themeStoreSortNewest: 'Newest',
|
||||
themeStoreSortName: 'Alphabetical',
|
||||
themeStoreByAuthor: 'by {{author}}',
|
||||
themeStoreLastChanged: 'Last changed',
|
||||
themeMigrationNoticeTitle: 'Theme moved to the Store',
|
||||
themeMigrationNoticeBody: '{{themes}} is no longer bundled with the app. You can reinstall it from the Theme Store (Settings → Themes), or pick another theme.',
|
||||
themeMigrationNoticeOpen: 'Open Theme Store',
|
||||
|
||||
@@ -38,8 +38,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Collapse playlists',
|
||||
more: 'More',
|
||||
feelingLucky: 'Lucky Mix',
|
||||
|
||||
themeUpdatesTitle: 'Theme updates',
|
||||
themeUpdatesTooltip: 'Theme updates available',
|
||||
themeUpdatesDismiss: 'Dismiss',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: '¿Volver a tu sesión de Orbit?',
|
||||
bodyHost: 'Estabas alojando «{{name}}». ¿Retomar la sesión donde la dejaste?',
|
||||
bodyGuest: '{{host}} está alojando «{{name}}». ¿Volver a unirte?',
|
||||
autoIn: 'Reconexión automática en {{seconds}} s',
|
||||
rejoin: 'Volver a unirse',
|
||||
stayOut: 'Quedarse fuera',
|
||||
reconnecting: 'Uniéndose…',
|
||||
failed: 'No se pudo volver a unir: puede que la sesión haya terminado.',
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Iniciar o unirse a una sesión de escucha compartida',
|
||||
launchCreate: 'Crear una sesión',
|
||||
|
||||
@@ -51,8 +51,6 @@ export const settings = {
|
||||
serverConnecting: 'Conectando…',
|
||||
serverConnected: '¡Conectado!',
|
||||
serverFailed: 'Conexión fallida.',
|
||||
serverAddUnreachable: 'No se pudo conectar con el servidor — comprueba la dirección, tu red y el certificado TLS del servidor. ({{detail}})',
|
||||
serverAddRejected: 'El servidor rechazó la conexión: {{message}}',
|
||||
testBtn: 'Probar Conexión',
|
||||
testingBtn: 'Probando…',
|
||||
serverCompatible: 'Diseñado para Navidrome. Otros servidores compatibles con Subsonic (Gonic, Airsonic, …) pueden funcionar con funcionalidad reducida, ya que Psysonic utiliza muchos endpoints específicos de la API de Navidrome.',
|
||||
@@ -107,10 +105,6 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Introduce la contraseña Subsonic de «{{username}}». Se incluirá en la cadena mágica copiada.',
|
||||
userMgmtMagicStringModalConfirm: 'Copiar cadena',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseStatusActive: 'Activo',
|
||||
audiomuseStatusChecking: 'Comprobando…',
|
||||
audiomuseStatusNotDetected: 'No detectado',
|
||||
audiomuseStatusProbeFailed: 'Error de comprobación',
|
||||
audiomuseDesc:
|
||||
'Activa si este servidor tiene el plugin <pluginLink>AudioMuse-AI Navidrome</pluginLink> configurado. Habilita Mezcla Instantánea desde pistas y usa artistas similares del servidor en lugar de Last.fm en páginas de artistas.',
|
||||
audiomuseIssueHint:
|
||||
@@ -156,29 +150,6 @@ export const settings = {
|
||||
cacheUsedHot: 'Tamaño en disco:',
|
||||
hotCacheTrackCount: 'Pistas en caché:',
|
||||
cacheMaxLabel: 'Tamaño máx.',
|
||||
imageCacheScopeLabel: 'Alcance',
|
||||
imageCacheSubTitle: 'Vistas previas en el navegador',
|
||||
coverDiskCacheSubTitle: 'Caché de portadas en disco',
|
||||
coverCacheStrategyTitle: 'Caché de portadas',
|
||||
coverCacheStrategyDesc: 'Puedes elegir una estrategia para cada servidor: Lazy — carga las portadas cuando hacen falta; Aggressive — carga cuando hacen falta más precarga adicional en segundo plano.',
|
||||
coverCacheStrategyLabel: 'Estrategia',
|
||||
coverCacheStrategyServerLabel: 'Servidor',
|
||||
coverCacheStrategyProgressLabel: 'Caché',
|
||||
coverCacheStrategyActionsLabel: 'Acciones',
|
||||
coverCacheStrategyLazy: 'Lazy',
|
||||
coverCacheStrategyLazyDesc: 'Carga las portadas solo cuando hacen falta.',
|
||||
coverCacheStrategyAggressive: 'Aggressive',
|
||||
coverCacheStrategyAggressiveDesc: 'Comportamiento Lazy más precarga adicional en segundo plano.',
|
||||
coverCacheStrategyDiskUsage: 'En disco: {{size}}',
|
||||
coverCacheStrategyProgressValue: '{{percent}}% ({{done}}/{{total}}) · {{size}}',
|
||||
coverCacheStrategyClearAction: 'Vaciar caché de portadas',
|
||||
coverCacheStrategyClearTitle: '¿Vaciar caché de portadas?',
|
||||
coverCacheStrategyClearDesc: 'Elimina todas las portadas en caché de {{server}}. Se volverán a descargar cuando hagan falta.',
|
||||
coverCacheStrategyClearConfirm: 'Vaciar',
|
||||
coverCacheStrategyClearCancel: 'Cancelar',
|
||||
coverCacheStrategyClearSuccess: 'Caché de portadas vaciada para este servidor.',
|
||||
coverCacheStrategyClearError: 'No se pudo vaciar la caché de portadas.',
|
||||
coverCacheStrategyServerRemoved: 'servidor eliminado',
|
||||
cacheClearBtn: 'Limpiar Caché',
|
||||
cacheClearWarning: 'Borra todas las portadas e imágenes de artistas en caché. Se volverán a descargar cuando hagan falta.',
|
||||
cacheClearConfirm: 'Limpiar caché de imágenes',
|
||||
@@ -355,8 +326,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Tienda de temas',
|
||||
themeStoreSubmitText: '¿Has creado tu propio tema? Compártelo con la comunidad — más información en el repositorio de temas.',
|
||||
themeStoreSubmitLink: 'Abrir el repositorio de temas',
|
||||
themeStoreNetworkNotice: 'La tienda de temas carga el catálogo y las vistas previas desde GitHub. No se envían datos personales.',
|
||||
themeStoreStatsNotice: 'Las fechas de última modificación se actualizan una vez al día (sobre las 04:17 UTC).',
|
||||
themeStoreNetworkNotice: 'La tienda de temas carga el catálogo y las vistas previas desde servicios externos (la CDN de jsDelivr y GitHub). No se envían datos personales.',
|
||||
themeStoreSearchPlaceholder: 'Buscar temas…',
|
||||
themeStoreFilterMode: 'Filtrar por modo',
|
||||
themeStoreModeAll: 'Todos',
|
||||
@@ -370,10 +340,7 @@ export const settings = {
|
||||
themeStoreEmpty: 'Ningún tema coincide con tus filtros.',
|
||||
themeStoreActive: 'Activo',
|
||||
themeStoreEnlarge: 'Ampliar vista previa',
|
||||
themeStoreSortNewest: 'Más recientes',
|
||||
themeStoreSortName: 'Alfabético',
|
||||
themeStoreByAuthor: 'por {{author}}',
|
||||
themeStoreLastChanged: 'Última modificación',
|
||||
themeMigrationNoticeTitle: 'Tema movido a la tienda',
|
||||
themeMigrationNoticeBody: '{{themes}} ya no viene incluido en la app. Puedes reinstalarlo desde la Tienda de Temas (Ajustes → Temas) o elegir otro tema.',
|
||||
themeMigrationNoticeOpen: 'Abrir Tienda de Temas',
|
||||
|
||||
@@ -35,8 +35,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Colapsar listas',
|
||||
more: 'Más',
|
||||
feelingLucky: 'Mezcla Suerte',
|
||||
|
||||
themeUpdatesTitle: 'Actualizaciones de temas',
|
||||
themeUpdatesTooltip: 'Hay actualizaciones de temas',
|
||||
themeUpdatesDismiss: 'Descartar',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: 'Rejoindre votre session Orbit ?',
|
||||
bodyHost: 'Vous hébergiez « {{name}} ». Reprendre la session là où vous l\'avez laissée ?',
|
||||
bodyGuest: '{{host}} héberge « {{name}} ». Rejoindre la session ?',
|
||||
autoIn: 'Reconnexion auto dans {{seconds}} s',
|
||||
rejoin: 'Rejoindre',
|
||||
stayOut: 'Rester en dehors',
|
||||
reconnecting: 'Connexion…',
|
||||
failed: 'Impossible de rejoindre — la session est peut-être terminée.',
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Démarrer ou rejoindre une session d\'écoute partagée',
|
||||
launchCreate: 'Créer une session',
|
||||
|
||||
@@ -51,8 +51,6 @@ export const settings = {
|
||||
serverConnecting: 'Connexion…',
|
||||
serverConnected: 'Connecté !',
|
||||
serverFailed: 'Connexion échouée.',
|
||||
serverAddUnreachable: 'Serveur injoignable — vérifiez l’adresse, votre réseau et le certificat TLS du serveur. ({{detail}})',
|
||||
serverAddRejected: 'Le serveur a refusé la connexion : {{message}}',
|
||||
testBtn: 'Tester la connexion',
|
||||
testingBtn: 'Test en cours…',
|
||||
serverCompatible: 'Conçu pour Navidrome. Les autres serveurs compatibles Subsonic (Gonic, Airsonic, …) peuvent fonctionner avec des fonctionnalités réduites, car Psysonic utilise de nombreux endpoints d’API spécifiques à Navidrome.',
|
||||
@@ -107,10 +105,6 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Saisissez le mot de passe Subsonic de « {{username}} ». Il est inclus dans la chaîne magique copiée.',
|
||||
userMgmtMagicStringModalConfirm: 'Copier la chaîne',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseStatusActive: 'Actif',
|
||||
audiomuseStatusChecking: 'Vérification…',
|
||||
audiomuseStatusNotDetected: 'Non détecté',
|
||||
audiomuseStatusProbeFailed: 'Échec de la détection',
|
||||
audiomuseDesc:
|
||||
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
|
||||
audiomuseIssueHint:
|
||||
@@ -156,29 +150,6 @@ export const settings = {
|
||||
cacheUsedHot: 'Espace disque :',
|
||||
hotCacheTrackCount: 'Pistes en cache :',
|
||||
cacheMaxLabel: 'Taille max.',
|
||||
imageCacheScopeLabel: 'Portée',
|
||||
imageCacheSubTitle: 'Aperçus dans le navigateur',
|
||||
coverDiskCacheSubTitle: 'Cache des pochettes sur disque',
|
||||
coverCacheStrategyTitle: 'Cache des pochettes',
|
||||
coverCacheStrategyDesc: 'Vous pouvez choisir une stratégie pour chaque serveur : Lazy — charger les pochettes au besoin ; Aggressive — charger au besoin et précharger en arrière-plan.',
|
||||
coverCacheStrategyLabel: 'Stratégie',
|
||||
coverCacheStrategyServerLabel: 'Serveur',
|
||||
coverCacheStrategyProgressLabel: 'Cache',
|
||||
coverCacheStrategyActionsLabel: 'Actions',
|
||||
coverCacheStrategyLazy: 'Lazy',
|
||||
coverCacheStrategyLazyDesc: 'Charge les pochettes uniquement lorsqu\'elles sont nécessaires.',
|
||||
coverCacheStrategyAggressive: 'Aggressive',
|
||||
coverCacheStrategyAggressiveDesc: 'Comportement Lazy avec préchargement supplémentaire en arrière-plan.',
|
||||
coverCacheStrategyDiskUsage: 'Sur le disque : {{size}}',
|
||||
coverCacheStrategyProgressValue: '{{percent}}% ({{done}}/{{total}}) · {{size}}',
|
||||
coverCacheStrategyClearAction: 'Vider le cache des pochettes',
|
||||
coverCacheStrategyClearTitle: 'Vider le cache des pochettes ?',
|
||||
coverCacheStrategyClearDesc: 'Supprimer toutes les pochettes en cache pour {{server}}. Elles seront retéléchargées si nécessaire.',
|
||||
coverCacheStrategyClearConfirm: 'Vider',
|
||||
coverCacheStrategyClearCancel: 'Annuler',
|
||||
coverCacheStrategyClearSuccess: 'Cache des pochettes vidé pour ce serveur.',
|
||||
coverCacheStrategyClearError: 'Échec du vidage du cache des pochettes.',
|
||||
coverCacheStrategyServerRemoved: 'serveur supprimé',
|
||||
cacheClearBtn: 'Vider le cache',
|
||||
cacheClearWarning: 'Efface toutes les pochettes et images d\'artistes en cache. Elles seront retéléchargées si nécessaire.',
|
||||
cacheClearConfirm: 'Vider le cache d\'images',
|
||||
@@ -353,8 +324,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Boutique de thèmes',
|
||||
themeStoreSubmitText: "Vous avez créé votre propre thème ? Partagez-le avec la communauté — plus d'infos dans le dépôt de thèmes.",
|
||||
themeStoreSubmitLink: 'Ouvrir le dépôt de thèmes',
|
||||
themeStoreNetworkNotice: 'Le Theme Store charge son catalogue et ses aperçus depuis GitHub. Aucune donnée personnelle n’est envoyée.',
|
||||
themeStoreStatsNotice: 'Les dates de dernière modification sont actualisées une fois par jour (vers 04h17 UTC).',
|
||||
themeStoreNetworkNotice: 'Le Theme Store charge son catalogue et ses aperçus depuis des services externes (le CDN jsDelivr et GitHub). Aucune donnée personnelle n’est envoyée.',
|
||||
themeStoreSearchPlaceholder: 'Rechercher des thèmes…',
|
||||
themeStoreFilterMode: 'Filtrer par mode',
|
||||
themeStoreModeAll: 'Tous',
|
||||
@@ -368,10 +338,7 @@ export const settings = {
|
||||
themeStoreEmpty: 'Aucun thème ne correspond à vos filtres.',
|
||||
themeStoreActive: 'Actif',
|
||||
themeStoreEnlarge: "Agrandir l'aperçu",
|
||||
themeStoreSortNewest: 'Plus récents',
|
||||
themeStoreSortName: 'Alphabétique',
|
||||
themeStoreByAuthor: 'par {{author}}',
|
||||
themeStoreLastChanged: 'Dernière modification',
|
||||
themeMigrationNoticeTitle: 'Thème déplacé vers la boutique',
|
||||
themeMigrationNoticeBody: "{{themes}} n'est plus inclus dans l'application. Vous pouvez le réinstaller depuis la boutique de thèmes (Paramètres → Thèmes) ou choisir un autre thème.",
|
||||
themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes',
|
||||
|
||||
@@ -34,8 +34,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Réduire les playlists',
|
||||
more: 'Plus',
|
||||
feelingLucky: 'Mix Chance',
|
||||
|
||||
themeUpdatesTitle: 'Mises à jour de thèmes',
|
||||
themeUpdatesTooltip: 'Mises à jour de thèmes disponibles',
|
||||
themeUpdatesDismiss: 'Masquer',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: 'Bli med i Orbit-økten igjen?',
|
||||
bodyHost: 'Du var vert for «{{name}}». Fortsette økten der du slapp?',
|
||||
bodyGuest: '{{host}} er vert for «{{name}}». Bli med igjen?',
|
||||
autoIn: 'Kobler til automatisk om {{seconds}} s',
|
||||
rejoin: 'Bli med igjen',
|
||||
stayOut: 'Hold deg utenfor',
|
||||
reconnecting: 'Kobler til…',
|
||||
failed: 'Kunne ikke bli med igjen – økten kan være avsluttet.',
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Start eller bli med i en delt lytteøkt',
|
||||
launchCreate: 'Opprett en økt',
|
||||
|
||||
@@ -51,8 +51,6 @@ export const settings = {
|
||||
serverConnecting: 'Kobler til…',
|
||||
serverConnected: 'Tilkoblet!',
|
||||
serverFailed: 'Tilkobling mislyktes.',
|
||||
serverAddUnreachable: 'Kunne ikke nå serveren — sjekk adressen, nettverket ditt og serverens TLS-sertifikat. ({{detail}})',
|
||||
serverAddRejected: 'Serveren avviste tilkoblingen: {{message}}',
|
||||
testBtn: 'Test tilkobling',
|
||||
testingBtn: 'Tester…',
|
||||
serverCompatible: 'Laget for Navidrome. Andre Subsonic-kompatible servere (Gonic, Airsonic, …) kan fungere med begrenset funksjonalitet, fordi Psysonic bruker mange Navidrome-spesifikke API-endepunkter.',
|
||||
@@ -107,10 +105,6 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Skriv inn Subsonic-passordet for «{{username}}». Det tas med i den kopierte magic string-en.',
|
||||
userMgmtMagicStringModalConfirm: 'Kopier streng',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseStatusActive: 'Aktiv',
|
||||
audiomuseStatusChecking: 'Sjekker…',
|
||||
audiomuseStatusNotDetected: 'Ikke oppdaget',
|
||||
audiomuseStatusProbeFailed: 'Sjekk mislyktes',
|
||||
audiomuseDesc:
|
||||
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
|
||||
audiomuseIssueHint:
|
||||
@@ -157,29 +151,6 @@ export const settings = {
|
||||
cacheUsedHot: 'Plass brukt:',
|
||||
hotCacheTrackCount: 'Spor i buffer:',
|
||||
cacheMaxLabel: 'Maks størrelse',
|
||||
imageCacheScopeLabel: 'Omfang',
|
||||
imageCacheSubTitle: 'Forhåndsvisninger i nettleseren',
|
||||
coverDiskCacheSubTitle: 'Plateomslag-cache på disk',
|
||||
coverCacheStrategyTitle: 'Cache for plateomslag',
|
||||
coverCacheStrategyDesc: 'Du kan velge en strategi for hver tjener: Lazy - last inn omslag ved behov; Aggressive - last inn ved behov pluss ekstra forhåndsinnlasting i bakgrunnen.',
|
||||
coverCacheStrategyLabel: 'Strategi',
|
||||
coverCacheStrategyServerLabel: 'Tjener',
|
||||
coverCacheStrategyProgressLabel: 'Cache',
|
||||
coverCacheStrategyActionsLabel: 'Handlinger',
|
||||
coverCacheStrategyLazy: 'Lazy',
|
||||
coverCacheStrategyLazyDesc: 'Laster inn omslag bare når de trengs.',
|
||||
coverCacheStrategyAggressive: 'Aggressive',
|
||||
coverCacheStrategyAggressiveDesc: 'Lazy-oppførsel pluss ekstra forhåndsinnlasting i bakgrunnen.',
|
||||
coverCacheStrategyDiskUsage: 'På disk: {{size}}',
|
||||
coverCacheStrategyProgressValue: '{{percent}}% ({{done}}/{{total}}) · {{size}}',
|
||||
coverCacheStrategyClearAction: 'Tøm omslag-cache',
|
||||
coverCacheStrategyClearTitle: 'Tøm omslag-cache?',
|
||||
coverCacheStrategyClearDesc: 'Fjern alle bufrede plateomslag for {{server}}. De lastes ned igjen ved behov.',
|
||||
coverCacheStrategyClearConfirm: 'Tøm',
|
||||
coverCacheStrategyClearCancel: 'Avbryt',
|
||||
coverCacheStrategyClearSuccess: 'Omslag-cache tømt for denne tjeneren.',
|
||||
coverCacheStrategyClearError: 'Kunne ikke tømme omslag-cache.',
|
||||
coverCacheStrategyServerRemoved: 'fjernet tjener',
|
||||
cacheClearBtn: 'Tøm hurtigbuffer',
|
||||
cacheClearWarning: 'Sletter alle bufrede omslag og artistbilder. De lastes ned igjen ved behov.',
|
||||
cacheClearConfirm: 'Tøm bildecache',
|
||||
@@ -356,8 +327,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Temabutikk',
|
||||
themeStoreSubmitText: 'Laget ditt eget tema? Del det med fellesskapet — mer info i tema-repoet.',
|
||||
themeStoreSubmitLink: 'Åpne tema-repoet',
|
||||
themeStoreNetworkNotice: 'Theme Store laster katalogen og forhåndsvisninger fra GitHub. Ingen personopplysninger sendes.',
|
||||
themeStoreStatsNotice: 'Sist endret-datoene oppdateres én gang om dagen (rundt 04:17 UTC).',
|
||||
themeStoreNetworkNotice: 'Theme Store laster katalogen og forhåndsvisninger fra eksterne tjenester (jsDelivr-CDN og GitHub). Ingen personopplysninger sendes.',
|
||||
themeStoreSearchPlaceholder: 'Søk i temaer…',
|
||||
themeStoreFilterMode: 'Filtrer etter modus',
|
||||
themeStoreModeAll: 'Alle',
|
||||
@@ -371,10 +341,7 @@ export const settings = {
|
||||
themeStoreEmpty: 'Ingen temaer samsvarer med filtrene dine.',
|
||||
themeStoreActive: 'Aktiv',
|
||||
themeStoreEnlarge: 'Forstørr forhåndsvisning',
|
||||
themeStoreSortNewest: 'Nyeste',
|
||||
themeStoreSortName: 'Alfabetisk',
|
||||
themeStoreByAuthor: 'av {{author}}',
|
||||
themeStoreLastChanged: 'Sist endret',
|
||||
themeMigrationNoticeTitle: 'Temaet er flyttet til butikken',
|
||||
themeMigrationNoticeBody: '{{themes}} følger ikke lenger med appen. Du kan installere det på nytt fra temabutikken (Innstillinger → Temaer), eller velge et annet tema.',
|
||||
themeMigrationNoticeOpen: 'Åpne temabutikken',
|
||||
|
||||
@@ -34,8 +34,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Skjul spillelister',
|
||||
more: 'Mer',
|
||||
feelingLucky: 'Lykkemiks',
|
||||
|
||||
themeUpdatesTitle: 'Temaoppdateringer',
|
||||
themeUpdatesTooltip: 'Temaoppdateringer tilgjengelig',
|
||||
themeUpdatesDismiss: 'Lukk',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: 'Weer in je Orbit-sessie?',
|
||||
bodyHost: 'Je hostte "{{name}}". De sessie hervatten waar je was gebleven?',
|
||||
bodyGuest: '{{host}} host "{{name}}". Weer deelnemen?',
|
||||
autoIn: 'Automatisch over {{seconds}} s',
|
||||
rejoin: 'Opnieuw deelnemen',
|
||||
stayOut: 'Erbuiten blijven',
|
||||
reconnecting: 'Deelnemen…',
|
||||
failed: 'Kon niet opnieuw deelnemen — de sessie is mogelijk beëindigd.',
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Een gedeelde luistersessie starten of eraan deelnemen',
|
||||
launchCreate: 'Sessie aanmaken',
|
||||
|
||||
@@ -51,8 +51,6 @@ export const settings = {
|
||||
serverConnecting: 'Verbinden…',
|
||||
serverConnected: 'Verbonden!',
|
||||
serverFailed: 'Verbinding mislukt.',
|
||||
serverAddUnreachable: 'Server niet bereikbaar — controleer het adres, je netwerk en het TLS-certificaat van de server. ({{detail}})',
|
||||
serverAddRejected: 'De server weigerde de verbinding: {{message}}',
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Gemaakt voor Navidrome. Andere Subsonic-compatibele servers (Gonic, Airsonic, …) kunnen met beperkte functionaliteit werken, omdat Psysonic veel Navidrome-specifieke API-endpoints gebruikt.',
|
||||
@@ -107,10 +105,6 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Voer het Subsonic-wachtwoord voor „{{username}}" in. Het wordt opgenomen in de gekopieerde magic string.',
|
||||
userMgmtMagicStringModalConfirm: 'String kopiëren',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseStatusActive: 'Actief',
|
||||
audiomuseStatusChecking: 'Controleren…',
|
||||
audiomuseStatusNotDetected: 'Niet gedetecteerd',
|
||||
audiomuseStatusProbeFailed: 'Detectie mislukt',
|
||||
audiomuseDesc:
|
||||
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpagina’s.',
|
||||
audiomuseIssueHint:
|
||||
@@ -156,29 +150,6 @@ export const settings = {
|
||||
cacheUsedHot: 'Schijfgebruik:',
|
||||
hotCacheTrackCount: 'Nummers in cache:',
|
||||
cacheMaxLabel: 'Max. grootte',
|
||||
imageCacheScopeLabel: 'Bereik',
|
||||
imageCacheSubTitle: 'Voorbeelden in browser',
|
||||
coverDiskCacheSubTitle: 'Hoescache op schijf',
|
||||
coverCacheStrategyTitle: 'Hoescache',
|
||||
coverCacheStrategyDesc: 'Je kunt per server een strategie kiezen: Lazy — laadt hoezen wanneer nodig; Aggressive — laadt wanneer nodig plus extra voorladen op de achtergrond.',
|
||||
coverCacheStrategyLabel: 'Strategie',
|
||||
coverCacheStrategyServerLabel: 'Server',
|
||||
coverCacheStrategyProgressLabel: 'Cache',
|
||||
coverCacheStrategyActionsLabel: 'Acties',
|
||||
coverCacheStrategyLazy: 'Lazy',
|
||||
coverCacheStrategyLazyDesc: 'Laadt hoezen alleen wanneer ze nodig zijn.',
|
||||
coverCacheStrategyAggressive: 'Aggressive',
|
||||
coverCacheStrategyAggressiveDesc: 'Lazy-gedrag plus extra voorladen op de achtergrond.',
|
||||
coverCacheStrategyDiskUsage: 'Op schijf: {{size}}',
|
||||
coverCacheStrategyProgressValue: '{{percent}}% ({{done}}/{{total}}) · {{size}}',
|
||||
coverCacheStrategyClearAction: 'Hoescache wissen',
|
||||
coverCacheStrategyClearTitle: 'Hoescache wissen?',
|
||||
coverCacheStrategyClearDesc: 'Verwijder alle gecachte hoesafbeeldingen voor {{server}}. Ze worden opnieuw gedownload wanneer nodig.',
|
||||
coverCacheStrategyClearConfirm: 'Wissen',
|
||||
coverCacheStrategyClearCancel: 'Annuleren',
|
||||
coverCacheStrategyClearSuccess: 'Hoescache gewist voor deze server.',
|
||||
coverCacheStrategyClearError: 'Hoescache wissen mislukt.',
|
||||
coverCacheStrategyServerRemoved: 'verwijderde server',
|
||||
cacheClearBtn: 'Cache wissen',
|
||||
cacheClearWarning: 'Wist alle gecachte hoesjes en artiestafbeeldingen. Ze worden opnieuw gedownload wanneer nodig.',
|
||||
cacheClearConfirm: 'Beeldcache wissen',
|
||||
@@ -353,8 +324,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Themawinkel',
|
||||
themeStoreSubmitText: 'Eigen thema gemaakt? Deel het met de community — meer info in de themarepository.',
|
||||
themeStoreSubmitLink: 'Themarepository openen',
|
||||
themeStoreNetworkNotice: 'De Theme Store laadt de catalogus en previews van GitHub. Er worden geen persoonlijke gegevens verzonden.',
|
||||
themeStoreStatsNotice: 'De laatst-gewijzigd-datums worden eenmaal per dag bijgewerkt (rond 04:17 UTC).',
|
||||
themeStoreNetworkNotice: 'De Theme Store laadt de catalogus en previews van externe diensten (de jsDelivr-CDN en GitHub). Er worden geen persoonlijke gegevens verzonden.',
|
||||
themeStoreSearchPlaceholder: "Thema's zoeken…",
|
||||
themeStoreFilterMode: 'Filteren op modus',
|
||||
themeStoreModeAll: 'Alle',
|
||||
@@ -368,10 +338,7 @@ export const settings = {
|
||||
themeStoreEmpty: "Geen thema's komen overeen met je filters.",
|
||||
themeStoreActive: 'Actief',
|
||||
themeStoreEnlarge: 'Voorbeeld vergroten',
|
||||
themeStoreSortNewest: 'Nieuwste',
|
||||
themeStoreSortName: 'Alfabetisch',
|
||||
themeStoreByAuthor: 'door {{author}}',
|
||||
themeStoreLastChanged: 'Laatst gewijzigd',
|
||||
themeMigrationNoticeTitle: 'Thema verplaatst naar de store',
|
||||
themeMigrationNoticeBody: '{{themes}} wordt niet meer met de app meegeleverd. Je kunt het opnieuw installeren via de Theme Store (Instellingen → Thema\'s) of een ander thema kiezen.',
|
||||
themeMigrationNoticeOpen: 'Theme Store openen',
|
||||
|
||||
@@ -34,8 +34,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Afspeellijsten inklappen',
|
||||
more: 'Meer',
|
||||
feelingLucky: 'Geluksmix',
|
||||
|
||||
themeUpdatesTitle: 'Thema-updates',
|
||||
themeUpdatesTooltip: 'Thema-updates beschikbaar',
|
||||
themeUpdatesDismiss: 'Sluiten',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const orbit = {
|
||||
reconnect: {
|
||||
title: 'Reintri în sesiunea Orbit?',
|
||||
bodyHost: 'Găzduiai „{{name}}". Reiei sesiunea de unde ai rămas?',
|
||||
bodyGuest: '{{host}} găzduiește „{{name}}". Reintri în sesiune?',
|
||||
autoIn: 'Reconectare automată în {{seconds}} s',
|
||||
rejoin: 'Reintră',
|
||||
stayOut: 'Rămâi afară',
|
||||
reconnecting: 'Se reconectează…',
|
||||
failed: 'Nu s-a putut reintra — sesiunea s-ar putea să se fi încheiat.',
|
||||
},
|
||||
triggerLabel: 'Orbit',
|
||||
triggerTooltip: 'Pornește sau intră în o sesiune de ascultare distribuită',
|
||||
launchCreate: 'Crează o sesiune',
|
||||
|
||||
@@ -51,8 +51,6 @@ export const settings = {
|
||||
serverConnecting: 'Se conectează…',
|
||||
serverConnected: 'Conectat!',
|
||||
serverFailed: 'Conexiune eșuată.',
|
||||
serverAddUnreachable: 'Serverul nu poate fi accesat — verifică adresa, rețeaua ta și certificatul TLS al serverului. ({{detail}})',
|
||||
serverAddRejected: 'Serverul a refuzat conexiunea: {{message}}',
|
||||
testBtn: 'Testează Conexiunea',
|
||||
testingBtn: 'Se testează…',
|
||||
serverCompatible: 'Făcut pentru Navidrome. Alte servere compatibile cu Subsonic (Gonic, Airsonic, …) pot rula cu funcționalitate redusă, deoarece Psysonic folosește multe endpointuri API specifice Navidrome.',
|
||||
@@ -107,10 +105,6 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Introdu parola Subsonic pentru "{{username}}". Este inclusă în string-ul magic copiat.',
|
||||
userMgmtMagicStringModalConfirm: 'Copiază string-ul',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseStatusActive: 'Activ',
|
||||
audiomuseStatusChecking: 'Se verifică…',
|
||||
audiomuseStatusNotDetected: 'Nedetectat',
|
||||
audiomuseStatusProbeFailed: 'Verificare eșuată',
|
||||
audiomuseDesc:
|
||||
'Pornește dacă acest server are <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configurat. Activează Mixul Instant din piese si folosește artiști similari de pe partea de server în loc de Last.fm pe paginile de artiști.',
|
||||
audiomuseIssueHint:
|
||||
@@ -156,29 +150,6 @@ export const settings = {
|
||||
cacheUsedHot: 'Mărimea pe disc:',
|
||||
hotCacheTrackCount: 'Piese în memoria cache:',
|
||||
cacheMaxLabel: 'Mărimea maximă',
|
||||
imageCacheScopeLabel: 'Domeniu',
|
||||
imageCacheSubTitle: 'Previzualizări în browser',
|
||||
coverDiskCacheSubTitle: 'Cache de coperte pe disc',
|
||||
coverCacheStrategyTitle: 'Cache pentru coperte',
|
||||
coverCacheStrategyDesc: 'Poți alege o strategie pentru fiecare server: Lazy — încarcă copertele la nevoie; Aggressive — încarcă la nevoie plus preîncărcare suplimentară în fundal.',
|
||||
coverCacheStrategyLabel: 'Strategie',
|
||||
coverCacheStrategyServerLabel: 'Server',
|
||||
coverCacheStrategyProgressLabel: 'Cache',
|
||||
coverCacheStrategyActionsLabel: 'Acțiuni',
|
||||
coverCacheStrategyLazy: 'Lazy',
|
||||
coverCacheStrategyLazyDesc: 'Încarcă copertele doar atunci când sunt necesare.',
|
||||
coverCacheStrategyAggressive: 'Aggressive',
|
||||
coverCacheStrategyAggressiveDesc: 'Comportamentul Lazy plus preîncărcare suplimentară în fundal.',
|
||||
coverCacheStrategyDiskUsage: 'Pe disc: {{size}}',
|
||||
coverCacheStrategyProgressValue: '{{percent}}% ({{done}}/{{total}}) · {{size}}',
|
||||
coverCacheStrategyClearAction: 'Golește cache-ul de coperte',
|
||||
coverCacheStrategyClearTitle: 'Golești cache-ul de coperte?',
|
||||
coverCacheStrategyClearDesc: 'Șterge toate copertele din cache pentru {{server}}. Vor fi descărcate din nou la nevoie.',
|
||||
coverCacheStrategyClearConfirm: 'Golește',
|
||||
coverCacheStrategyClearCancel: 'Anulează',
|
||||
coverCacheStrategyClearSuccess: 'Cache-ul de coperte a fost golit pentru acest server.',
|
||||
coverCacheStrategyClearError: 'Golirea cache-ului de coperte a eșuat.',
|
||||
coverCacheStrategyServerRemoved: 'server eliminat',
|
||||
cacheClearBtn: 'Golește memoria cache',
|
||||
waveformCacheClearBtn: 'Golește cache-ul formelor de undă',
|
||||
cacheClearWarning: 'Șterge toate copertele și imaginile artiștilor din cache. Vor fi descărcate din nou la nevoie.',
|
||||
@@ -359,8 +330,7 @@ export const settings = {
|
||||
themeStoreTitle: 'Magazin de teme',
|
||||
themeStoreSubmitText: 'Ți-ai creat propria temă? Împărtășește-o cu comunitatea — mai multe informații în depozitul de teme.',
|
||||
themeStoreSubmitLink: 'Deschide depozitul de teme',
|
||||
themeStoreNetworkNotice: 'Theme Store încarcă catalogul și previzualizările de la GitHub. Nu se trimit date personale.',
|
||||
themeStoreStatsNotice: 'Datele privind ultima modificare se actualizează o dată pe zi (în jurul orei 04:17 UTC).',
|
||||
themeStoreNetworkNotice: 'Theme Store încarcă catalogul și previzualizările de la servicii externe (CDN-ul jsDelivr și GitHub). Nu se trimit date personale.',
|
||||
themeStoreSearchPlaceholder: 'Caută teme…',
|
||||
themeStoreFilterMode: 'Filtrează după mod',
|
||||
themeStoreModeAll: 'Toate',
|
||||
@@ -374,10 +344,7 @@ export const settings = {
|
||||
themeStoreEmpty: 'Nicio temă nu corespunde filtrelor tale.',
|
||||
themeStoreActive: 'Activă',
|
||||
themeStoreEnlarge: 'Mărește previzualizarea',
|
||||
themeStoreSortNewest: 'Cele mai noi',
|
||||
themeStoreSortName: 'Alfabetic',
|
||||
themeStoreByAuthor: 'de {{author}}',
|
||||
themeStoreLastChanged: 'Ultima modificare',
|
||||
themeMigrationNoticeTitle: 'Tema a fost mutată în magazin',
|
||||
themeMigrationNoticeBody: '{{themes}} nu mai este inclusă în aplicație. O poți reinstala din Magazinul de Teme (Setări → Teme) sau poți alege altă temă.',
|
||||
themeMigrationNoticeOpen: 'Deschide Magazinul de Teme',
|
||||
|
||||
@@ -36,8 +36,4 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Restrânge playlisturi',
|
||||
more: 'Mai mult',
|
||||
feelingLucky: 'Mix Norocos',
|
||||
|
||||
themeUpdatesTitle: 'Actualizări de teme',
|
||||
themeUpdatesTooltip: 'Actualizări de teme disponibile',
|
||||
themeUpdatesDismiss: 'Închide',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user