mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-27 01:27:41 +00:00
Compare commits
46 Commits
app-v1.34.6
...
v1.34.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 28b23a9de1 | |||
| 649f5223b4 | |||
| 1cc674e4ee | |||
| 41e98d5783 | |||
| 6ffcd6f6fa | |||
| 33a15fd17a | |||
| 084543e59b | |||
| b0081e3d7a | |||
| ccc9f2cae5 | |||
| f34bd7c0f1 | |||
| 8cb5eb9384 | |||
| 02a4c43f61 | |||
| c49f6af38b | |||
| 69c0c5a907 | |||
| 4de577eede | |||
| c9dfbcc19f | |||
| 36f3d42dbe | |||
| 49f7fe5f6e | |||
| 28943f1ecb | |||
| 46cefb5712 | |||
| 74985fe331 | |||
| 4861d99cb1 | |||
| d2592839b0 | |||
| bbeb2f661e | |||
| 5f0fb5dcbd | |||
| 6f6cb0fd6b | |||
| 15dc970f53 | |||
| fc40d235d0 | |||
| f304589ea1 | |||
| 22f7de45e8 | |||
| 05da369aad | |||
| 9671f89a48 | |||
| 9567a94dea | |||
| 1ba8619a37 | |||
| 973b472c1f | |||
| 20bf93c344 | |||
| fd834314ba | |||
| a78c0fe9ac | |||
| 9c57d4f887 | |||
| 5d067b1f8b | |||
| 48d0145dc8 | |||
| 0da92c2fa1 | |||
| 4e3efa3814 | |||
| 77085a544e | |||
| 8ffecb4b7c | |||
| c569ff5f34 |
@@ -40,3 +40,6 @@ memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
# Third-party clones for local research (not committed)
|
||||
research/
|
||||
|
||||
@@ -5,6 +5,104 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.34.8] - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Netease Cloud Music Lyrics** *(opt-in)*: Netease Cloud Music can now be enabled in Settings → General as a last-resort lyrics fallback. It only fires when neither the server nor LRCLIB return results — the existing lyrics chain is completely unaffected. Particularly useful for Asian and international music. Chinese metadata lines (作词/作曲/编曲 etc.) are automatically stripped from the LRC output.
|
||||
|
||||
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now automatically detects whether [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic falls back to its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
|
||||
|
||||
- **ICY metadata & AzuraCast radio** *(contributed by [@sorensiimSalling](https://github.com/sorensiimSalling), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
|
||||
|
||||
- **Automatic audio device switching**: Psysonic now detects newly connected or changed audio output devices and switches to them automatically — no app restart required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Multi-artist tracks**: Tracks with multiple artists (OpenSubsonic `artists[]` field, e.g. semicolon-separated entries) now display each artist individually. Artists with their own profile page are clickable links; artists without one appear as plain text. Separated by `·`.
|
||||
|
||||
- **Gapless + Preload Gate**: The gapless chain and the preload gate now run on separate paths. Previously both could fire simultaneously, causing a brief black flash on track change.
|
||||
|
||||
- **Replay Gain — missing album gain**: When no album gain tag is present, Psysonic now correctly falls back to track gain instead of skipping gain correction entirely.
|
||||
|
||||
- **Statistics — music library scope**: Genre insights now respect the currently selected music library. Fetch results are cached to avoid redundant server requests. Playback durations are displayed in localized units.
|
||||
|
||||
- **Russian locale**: "Most Played" in the sidebar, home page, and page title now uses «Популярное».
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Reset to defaults" buttons** in Settings → Input are now styled as warning buttons (red border).
|
||||
- **Lyrics button** removed from the player bar (redundant with the queue panel tab).
|
||||
- **Icons**: Advanced search now uses the `TextSearch` icon; artist bio button now uses `Highlighter`.
|
||||
- **Album chip** in the album detail header is now opaque across all themes.
|
||||
- **Hot Cache and Hi-Res Audio**: Alpha badges removed — both features are production-ready.
|
||||
- **CPU optimisations**: Next-track buffering and preload settings have been consolidated into a unified control.
|
||||
|
||||
### Theme Fixes
|
||||
|
||||
- **Middle Earth**: Removed vertical stripe pattern from sidebar; improved queue artist contrast on hover; fixed album detail artist colour, bio text, and "Read more" link readability; "Next Tracks" divider label is now lighter.
|
||||
- **Toy Tale**: Fixed sidebar section labels (System/Library), queue tab buttons (Lyrics/Queue), inactive artist text, and "Next Tracks" divider label — all were too dark to read.
|
||||
- **Tetrastack**: Raised all purple and blue palette values (`#a020f0` → `#c070ff`, `#0060f0` → `#4090ff`); raised `--text-muted` from `#3a3a6a` to `#7878b8` — affected settings descriptions, artist names in tracklists, and queue labels.
|
||||
- **Horde & Alliance**: Removed repeating horizontal line pattern from sidebar.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — AudioMuse-AI Navidrome integration (PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))
|
||||
- [@sorensiimSalling](https://github.com/sorensiimSalling) — ICY metadata & AzuraCast radio support (PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))
|
||||
|
||||
You make Psysonic better. 🙌
|
||||
|
||||
---
|
||||
|
||||
## [1.34.7] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows — Taskbar Thumbnail Toolbar**: Prev / Play-Pause / Next media buttons now appear in the Windows taskbar thumbnail preview (the popup that appears when hovering over the taskbar icon). Buttons emit the same `media:*` events as the tray menu and souvlaki. The Play/Pause icon swaps in real-time as playback state changes.
|
||||
|
||||
- **Windows — High-quality taskbar icons**: The taskbar thumbnail toolbar icons are now loaded from embedded `.ico` assets (`play.ico`, `pause.ico`, `prev.ico`, `next.ico`) via `CreateIconFromResourceEx`, replacing the previous monochrome GDI drawing code. All four icons are properly cleaned up on window destruction.
|
||||
|
||||
- **Professional update modal**: The in-app updater now shows a polished modal with the full release changelog, a **Skip this version** option, and an OS-aware direct download button (`.dmg` on macOS, `.exe` on Windows, `.deb`/`.rpm` on Linux) as a fallback if the auto-update fails. The modal is fully localised in all 7 supported languages.
|
||||
|
||||
- **Self-hosted fonts — no internet required**: All 10 UI fonts are now shipped as WOFF2 files bundled into the app via `@fontsource-variable` npm packages. The previous Google Fonts CDN dependency has been removed entirely — Psysonic now renders correctly with no internet connection and without any external requests on startup.
|
||||
|
||||
- **Help — 11 new FAQ entries**: The Help page covers previously undocumented features across Ratings (how to rate songs/albums/artists, removing a rating, Skip-to-1★, rating filter for mixes), Folder Browser, Theme Scheduler, UI Scale, Seekbar styles, AutoEQ, Replay Gain, Hot Cache, and offline playlist caching. All 7 locales updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Embedded lyrics (MP3 & FLAC)**: A new `get_embedded_lyrics` Tauri command reads lyrics tags directly from local files — `SYLT`/`USLT` frames for MP3 (via the `id3` crate) and `SYNCEDLYRICS`/`LYRICS` tags for FLAC (via `lofty`). Additionally: the LRC parser now correctly handles timestamps without fractional seconds (e.g. `[01:23]`), and the Subsonic structured-lyrics parser now accepts both `synced` and `issynced` field names for compatibility with different server versions.
|
||||
|
||||
- **Linux — player bar disappearing at high zoom / small window sizes**: All `grid-template-rows` definitions now use `minmax(0, 1fr)` instead of bare `1fr`, and the `min-height: 720px` constraint on the app shell has been removed. The player bar no longer gets pushed off-screen when the window is small or the UI scale is above 100 %.
|
||||
|
||||
- **Windows — "Open folder" in Settings crashing**: The Settings page uses a Rust `open_folder` command instead of the Tauri `shell:open` API, which was blocked by the capability scope on Windows for local paths.
|
||||
|
||||
- **macOS — Artist Radio crashing WKWebView after ~10 minutes**: Storing `currentTime` in the persisted Zustand state caused up to ~1,200 synchronous `localStorage.setItem` calls per radio session, eventually crashing the WKWebView SQLite backend. `currentTime` has been removed from the persist partializer. Old played radio tracks are also now trimmed from the queue (keeping the last 5) to cap the localStorage payload during queue top-up.
|
||||
|
||||
- **Artist Radio — predictable track order**: The initial Artist Radio queue is now shuffled via Fisher-Yates, so positions 2+ draw from similar-artist tracks in a random order rather than always playing the server's top-5 tracks in sequence.
|
||||
|
||||
- **Internet Radio — stall / buffering recovery**: Stall events on the HTML5 `<audio>` element now trigger automatic reconnection (up to 5 retries), recovering from transient network interruptions without requiring a manual restart.
|
||||
|
||||
- **Corrupt MP3s — VLC-style frame tolerance**: The audio decoder now tolerates up to 100 consecutive bad frames before giving up (previously 3), matching VLC's behavior for files with invalid `main_data` offset frames. Frame-drop log messages are suppressed in release builds.
|
||||
|
||||
- **Statistics — album/song totals respect selected music library**: Album and track counts on the Statistics page were previously derived from `getGenres()`, which is not scoped to the active music folder. Both counts are now derived from the same paginated `getAlbumList` pass used for playtime, with the same 5,000-album cap and a `≥` prefix when capped. *(PR [#138](https://github.com/Psychotoxical/psysonic/pull/138) by [@cucadmuh](https://github.com/cucadmuh))*
|
||||
|
||||
- **Fullscreen — resize grips visible in native fullscreen**: Resize grips are now hidden whenever the window enters native fullscreen on all platforms (previously only tracked on Linux). An initial check on mount also catches windows that start in a maximized or fullscreen state.
|
||||
|
||||
- **Albums page — year filter input height**: The "From year" / "To year" inputs in the Albums filter bar now match the height and font size of adjacent buttons, fixing the mixed-height row introduced in v1.34.4.
|
||||
|
||||
- **Russian locale — missing lyrics-source strings**: The `lyricsServerFirst` and related settings strings were not translated in the Russian locale. *(PR [#140](https://github.com/Psychotoxical/psysonic/pull/140) by [@kilyabin](https://github.com/kilyabin))*
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — Statistics music-folder scope fix (PR [#138](https://github.com/Psychotoxical/psysonic/pull/138))
|
||||
- [@kilyabin](https://github.com/kilyabin) — Russian locale lyrics strings (PR [#140](https://github.com/Psychotoxical/psysonic/pull/140))
|
||||
|
||||
---
|
||||
|
||||
## [1.34.6] - 2026-04-08
|
||||
|
||||
> I'm sorry this is already the third release today — every time we shipped a critical fix, another critical issue surfaced. Hopefully this one holds. 🤞
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
||||
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+102
-2
@@ -1,13 +1,23 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.34.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.34.7",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -773,6 +783,96 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/figtree": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/figtree/-/figtree-5.2.10.tgz",
|
||||
"integrity": "sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lexend": {
|
||||
"version": "5.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/nunito": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.2.7.tgz",
|
||||
"integrity": "sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/outfit": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
|
||||
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/plus-jakarta-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-iQecBizIdZxezODNHzOn4SvvRMrZL/S8k4MEXGDynCmUrImVW0VmX+tIAMqnADwH4haXlHSXqMgU6+kcfBQJdw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/rubik": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.2.8.tgz",
|
||||
"integrity": "sha512-vGDExLzB4a2Fj9mca5LqNoA2ZKcU9o+x5FEBLte/nxYkCB9hOQwZS6ZlItUv+Ssn7YMzKauGuI/Po+YueFuZbg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/space-grotesk": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz",
|
||||
"integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.6",
|
||||
"version": "1.34.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,6 +12,16 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.6
|
||||
pkgver=1.34.7
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+132
-7
@@ -907,6 +907,12 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -2058,6 +2064,17 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "id3"
|
||||
version = "1.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -2420,6 +2437,32 @@ dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lofty"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"data-encoding",
|
||||
"flate2",
|
||||
"lofty_attr",
|
||||
"log",
|
||||
"ogg_pager",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lofty_attr"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
@@ -2881,6 +2924,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg_pager"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -2979,6 +3031,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3339,11 +3397,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.5"
|
||||
version = "1.34.7"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"lofty",
|
||||
"md5",
|
||||
"reqwest 0.12.28",
|
||||
"ringbuf",
|
||||
@@ -3365,6 +3425,7 @@ dependencies = [
|
||||
"thread-priority",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5865,8 +5926,8 @@ dependencies = [
|
||||
"webview2-com-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5956,6 +6017,16 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -5988,14 +6059,27 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -6007,8 +6091,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -6025,6 +6109,17 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -6036,6 +6131,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -6078,6 +6184,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -6096,6 +6211,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.6"
|
||||
version = "1.34.8"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -44,6 +44,19 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"]
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
id3 = "1.16.4"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
+151
-18
@@ -890,7 +890,13 @@ impl MediaSource for SizedCursorSource {
|
||||
// Implements Iterator<Item = i16> + Source — identical interface to
|
||||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||||
|
||||
/// Max retries for IO/packet-read errors (fatal — network drop, truncated file).
|
||||
const DECODE_MAX_RETRIES: usize = 3;
|
||||
/// Max *consecutive* DecodeErrors before giving up on a file.
|
||||
/// Non-fatal errors like "invalid main_data offset" are silently dropped up to
|
||||
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
|
||||
/// playable track (VLC-style frame dropping).
|
||||
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
|
||||
|
||||
struct SizedDecoder {
|
||||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||
@@ -899,6 +905,9 @@ struct SizedDecoder {
|
||||
total_duration: Option<Time>,
|
||||
buffer: SampleBuffer<i16>,
|
||||
spec: SignalSpec,
|
||||
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
||||
/// successfully decoded frame. Used to detect fully undecodable streams.
|
||||
consecutive_decode_errors: usize,
|
||||
}
|
||||
|
||||
impl SizedDecoder {
|
||||
@@ -983,6 +992,8 @@ impl SizedDecoder {
|
||||
let mut format = probed.format;
|
||||
|
||||
// Decode the first packet to initialise spec + buffer.
|
||||
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
|
||||
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
|
||||
let mut decode_errors: usize = 0;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
@@ -1002,10 +1013,10 @@ impl SizedDecoder {
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => break decoded,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
eprintln!("[psysonic] decode error (retry {decode_errors}): {msg}");
|
||||
decode_errors += 1;
|
||||
if decode_errors > DECODE_MAX_RETRIES {
|
||||
return Err("too many decode errors — file may be corrupt".into());
|
||||
eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}");
|
||||
if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1025,6 +1036,7 @@ impl SizedDecoder {
|
||||
total_duration,
|
||||
buffer,
|
||||
spec,
|
||||
consecutive_decode_errors: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1061,16 +1073,19 @@ impl SizedDecoder {
|
||||
if packet.track_id() != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
Ok(d) => break d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
errors += 1;
|
||||
if errors > DECODE_MAX_RETRIES { return Err("radio: too many decode errors".into()); }
|
||||
eprintln!("[psysonic] radio init: dropped corrupt frame #{errors}: {msg}");
|
||||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("radio: too many consecutive decode errors".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("radio: decode error: {e}")),
|
||||
}
|
||||
};
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec })
|
||||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1120,18 +1135,45 @@ impl Iterator for SizedDecoder {
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<i16> {
|
||||
if self.current_frame_offset >= self.buffer.len() {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
let mut decoded = self.decoder.decode(&packet);
|
||||
for _ in 0..DECODE_MAX_RETRIES {
|
||||
if decoded.is_err() {
|
||||
let p = self.format.next_packet().ok()?;
|
||||
decoded = self.decoder.decode(&p);
|
||||
// Loop until a decodable packet is found or the stream ends.
|
||||
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
||||
// drop the frame and advance to the next packet. IO errors and a
|
||||
// clean end-of-stream both terminate the iterator normally.
|
||||
loop {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
match self.decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
self.consecutive_decode_errors = 0;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = 0;
|
||||
break;
|
||||
}
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
self.consecutive_decode_errors += 1;
|
||||
// Log sparingly: first drop, then every 10th to avoid spam.
|
||||
#[cfg(debug_assertions)]
|
||||
if self.consecutive_decode_errors == 1
|
||||
|| self.consecutive_decode_errors % 10 == 0
|
||||
{
|
||||
eprintln!(
|
||||
"[psysonic] dropped corrupt frame #{}: {msg}",
|
||||
self.consecutive_decode_errors
|
||||
);
|
||||
}
|
||||
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!(
|
||||
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
||||
failures — stream appears unrecoverable, stopping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// continue → fetch next packet
|
||||
}
|
||||
Err(_) => return None, // IO error or fatal codec error → end of stream
|
||||
}
|
||||
}
|
||||
let decoded = decoded.ok()?;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = 0;
|
||||
}
|
||||
|
||||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||||
@@ -1642,6 +1684,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
http_client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.use_rustls_tls()
|
||||
.user_agent(format!("psysonic/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
@@ -1727,6 +1770,24 @@ async fn fetch_data(
|
||||
}
|
||||
|
||||
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let status = response.status();
|
||||
let ct = response.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("-");
|
||||
let server_hdr = response.headers()
|
||||
.get("server")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("-");
|
||||
// Strip auth params from URL before logging.
|
||||
let safe_url = url.split('?').next().unwrap_or(url);
|
||||
eprintln!(
|
||||
"[audio] fetch {} → {} | content-type: {} | server: {}",
|
||||
safe_url, status, ct, server_hdr
|
||||
);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None); // superseded
|
||||
@@ -2284,8 +2345,8 @@ fn spawn_progress_task(
|
||||
let mut samples_played = samples_played;
|
||||
|
||||
loop {
|
||||
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
// 500 ms tick — frontend interpolates visually at 60 fps via rAF.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
@@ -2822,3 +2883,75 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// ─── Device-change watcher ────────────────────────────────────────────────────
|
||||
//
|
||||
// Polls the OS default output device every 3 s. When it changes (Bluetooth
|
||||
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
|
||||
// the new device and `audio:device-changed` is emitted so the frontend can
|
||||
// restart playback. The old Sink is dropped here — it was bound to the
|
||||
// now-closed OutputStream and can no longer produce audio on any device.
|
||||
|
||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||
let stream_handle = engine.stream_handle.clone();
|
||||
let stream_rate = engine.stream_sample_rate.clone();
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let current_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
if current_name == last_name {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_name = current_name.clone();
|
||||
|
||||
// Only act if there is actually a device to open.
|
||||
let Some(_new_name) = current_name else { continue };
|
||||
|
||||
// Debounce: give the OS time to finish configuring the new device.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let rate = stream_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx2 = reopen_tx.clone();
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
if reopen_tx2.send((rate, false, reply_tx)).is_err() {
|
||||
return None; // audio thread exited
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
let Some(handle) = new_handle else {
|
||||
eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
continue;
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
|
||||
// Drop the old Sink — it was bound to the now-closed OutputStream.
|
||||
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
|
||||
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
||||
|
||||
app.emit("audio:device-changed", ()).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
mod audio;
|
||||
mod discord;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod taskbar_win;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -248,6 +250,146 @@ async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
||||
Ok((bytes.to_vec(), content_type))
|
||||
}
|
||||
|
||||
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
|
||||
/// Returns the response body as a UTF-8 string for parsing on the JS side.
|
||||
#[tauri::command]
|
||||
async fn fetch_json_url(url: String) -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let text = resp.text().await.map_err(|e| e.to_string())?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// ICY metadata response returned to the frontend.
|
||||
#[derive(serde::Serialize)]
|
||||
struct IcyMetadata {
|
||||
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
|
||||
stream_title: Option<String>,
|
||||
/// Value of the `icy-name` response header.
|
||||
icy_name: Option<String>,
|
||||
/// Value of the `icy-genre` response header.
|
||||
icy_genre: Option<String>,
|
||||
/// Value of the `icy-url` response header.
|
||||
icy_url: Option<String>,
|
||||
/// Value of the `icy-description` response header.
|
||||
icy_description: Option<String>,
|
||||
}
|
||||
|
||||
/// Fetch ICY in-stream metadata from a radio stream URL.
|
||||
///
|
||||
/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes
|
||||
/// (up to `icy-metaint` audio bytes plus the following metadata block) to
|
||||
/// extract the `StreamTitle`. The connection is dropped as soon as the
|
||||
/// first metadata chunk has been parsed, so bandwidth usage is minimal.
|
||||
#[tauri::command]
|
||||
async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Harvest ICY headers before consuming the body.
|
||||
let headers = resp.headers();
|
||||
let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let metaint: Option<usize> = headers
|
||||
.get("icy-metaint")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok());
|
||||
|
||||
// If the server doesn't advertise a metaint we can still return header info.
|
||||
let Some(metaint) = metaint else {
|
||||
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||
};
|
||||
|
||||
// Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks.
|
||||
let metaint = metaint.min(65_536);
|
||||
let needed = metaint + 1; // +1 for the metadata-length byte
|
||||
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(needed + 256);
|
||||
let mut stream = resp.bytes_stream();
|
||||
|
||||
while buf.len() < needed {
|
||||
let Some(chunk) = stream.next().await else { break };
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if buf.len() < needed {
|
||||
// Stream ended before we reached the metadata block.
|
||||
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||
}
|
||||
|
||||
// The byte immediately after `metaint` audio bytes encodes metadata length:
|
||||
// actual_bytes = length_byte * 16
|
||||
let meta_len = buf[metaint] as usize * 16;
|
||||
if meta_len == 0 {
|
||||
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||
}
|
||||
|
||||
// We may need to read a few more chunks to get the full metadata block.
|
||||
let total_needed = needed + meta_len;
|
||||
while buf.len() < total_needed {
|
||||
let Some(chunk) = stream.next().await else { break };
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
let meta_start = needed; // index of first metadata byte
|
||||
let meta_end = (meta_start + meta_len).min(buf.len());
|
||||
let meta_bytes = &buf[meta_start..meta_end];
|
||||
|
||||
// ICY metadata is Latin-1 encoded; convert to a Rust String lossily.
|
||||
let meta_str: String = meta_bytes
|
||||
.iter()
|
||||
.map(|&b| if b == 0 { '\0' } else { b as char })
|
||||
.collect::<String>();
|
||||
|
||||
// Parse StreamTitle='...' — value ends at the next unescaped single-quote.
|
||||
let stream_title = meta_str
|
||||
.split("StreamTitle='")
|
||||
.nth(1)
|
||||
.and_then(|s| {
|
||||
// Find closing quote that is NOT preceded by a backslash.
|
||||
let mut prev = '\0';
|
||||
let mut end = s.len();
|
||||
for (i, c) in s.char_indices() {
|
||||
if c == '\'' && prev != '\\' {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
let title = s[..end].trim().to_string();
|
||||
if title.is_empty() { None } else { Some(title) }
|
||||
});
|
||||
|
||||
Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description })
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
@@ -611,6 +753,288 @@ fn resolve_hot_cache_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the current Linux system is Arch-based
|
||||
/// (checks /etc/arch-release and /etc/os-release).
|
||||
#[tauri::command]
|
||||
fn check_arch_linux() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::path::Path::new("/etc/arch-release").exists() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
let lower = line.to_lowercase();
|
||||
if lower.starts_with("id=arch") { return true; }
|
||||
if lower.starts_with("id_like=") && lower.contains("arch") { return true; }
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ false }
|
||||
}
|
||||
|
||||
/// Progress payload emitted during an update binary download.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateDownloadProgress {
|
||||
bytes: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Downloads an update installer/package to the user's Downloads folder.
|
||||
/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms.
|
||||
/// Returns the final absolute file path on success.
|
||||
#[tauri::command]
|
||||
async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result<String, String> {
|
||||
use futures_util::StreamExt;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const EMIT_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
let dest_dir = app.path().download_dir().map_err(|e| e.to_string())?;
|
||||
let dest_path = dest_dir.join(&filename);
|
||||
let part_path = dest_dir.join(format!("{}.part", filename));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.timeout(Duration::from_secs(3600))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
let total = response.content_length();
|
||||
|
||||
let result: Result<u64, String> = async {
|
||||
let mut file = tokio::fs::File::create(&part_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut bytes_done: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
||||
bytes_done += chunk.len() as u64;
|
||||
|
||||
if last_emit.elapsed() >= EMIT_INTERVAL {
|
||||
let _ = app.emit("update:download:progress", UpdateDownloadProgress {
|
||||
bytes: bytes_done,
|
||||
total,
|
||||
});
|
||||
last_emit = Instant::now();
|
||||
}
|
||||
}
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
Ok(bytes_done)
|
||||
}.await;
|
||||
|
||||
match result {
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
Err(e)
|
||||
}
|
||||
Ok(bytes_done) => {
|
||||
let _ = app.emit("update:download:progress", UpdateDownloadProgress {
|
||||
bytes: bytes_done,
|
||||
total: Some(bytes_done),
|
||||
});
|
||||
tokio::fs::rename(&part_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(dest_path.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches synced lyrics from Netease Cloud Music for a given artist + title.
|
||||
/// Performs a track search, then fetches the LRC string for the best match.
|
||||
/// Returns `None` if no match or no lyrics are found.
|
||||
#[tauri::command]
|
||||
async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let query = format!("{} {}", artist, title);
|
||||
let params = [("s", query.as_str()), ("type", "1"), ("limit", "5")];
|
||||
let search: serde_json::Value = client
|
||||
.post("https://music.163.com/api/search/get")
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", "https://music.163.com")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let song_id = match search["result"]["songs"][0]["id"].as_i64() {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let lyrics: serde_json::Value = client
|
||||
.get(format!(
|
||||
"https://music.163.com/api/song/lyric?id={}&lv=1&kv=1&tv=-1",
|
||||
song_id
|
||||
))
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", "https://music.163.com")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let lrc = lyrics["lrc"]["lyric"].as_str().unwrap_or("").trim().to_string();
|
||||
Ok(if lrc.is_empty() { None } else { Some(lrc) })
|
||||
}
|
||||
|
||||
/// Reads embedded synced / unsynced lyrics from a local audio file.
|
||||
///
|
||||
/// Priority order:
|
||||
/// MP3 → ID3v2 SYLT (synchronized, ms timestamps) → ID3v2 USLT (plain)
|
||||
/// FLAC → Vorbis SYNCEDLYRICS (LRC string) → Vorbis LYRICS (plain)
|
||||
///
|
||||
/// Returns a standard LRC string (`[mm:ss.cc]line\n…`) for synced lyrics,
|
||||
/// or plain text for unsynced lyrics. Returns `None` when no lyrics are found.
|
||||
/// Errors are silenced and mapped to `None` so the frontend falls through to the
|
||||
/// next lyrics source without crashing.
|
||||
#[tauri::command]
|
||||
fn get_embedded_lyrics(path: String) -> Option<String> {
|
||||
use lofty::file::FileType;
|
||||
use lofty::prelude::*;
|
||||
use lofty::probe::Probe;
|
||||
|
||||
let fpath = std::path::Path::new(&path);
|
||||
if !fpath.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Detect file type from magic bytes only — no full tag read yet.
|
||||
// guess_file_type() consumes self and returns Self, so reassign.
|
||||
let probe = Probe::open(fpath).ok()?;
|
||||
let probe = probe.guess_file_type().ok()?;
|
||||
let file_type = probe.file_type();
|
||||
|
||||
// ── MP3 / MPEG: use the `id3` crate for SYLT / USLT ─────────────────────
|
||||
// lofty's MpegFile::id3v2_tag field is pub(crate) — not accessible here.
|
||||
// The `id3` crate exposes a clean public API for typed ID3v2 frames.
|
||||
if matches!(file_type, Some(FileType::Mpeg)) {
|
||||
use id3::{Content, Tag as Id3Tag};
|
||||
|
||||
if let Ok(tag) = Id3Tag::read_from_path(fpath) {
|
||||
// 1. SYLT — millisecond-timestamped synced lyrics.
|
||||
for frame in tag.frames() {
|
||||
if frame.id() != "SYLT" {
|
||||
continue;
|
||||
}
|
||||
if let Content::SynchronisedLyrics(sylt) = frame.content() {
|
||||
// Only accept millisecond timestamps — MPEG-frame-based
|
||||
// timestamps can't be converted to wall-clock seconds.
|
||||
if sylt.timestamp_format != id3::frame::TimestampFormat::Ms {
|
||||
continue;
|
||||
}
|
||||
let lrc: String = sylt
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|(ms, text)| {
|
||||
let t = text.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mins = ms / 60_000;
|
||||
let secs = (ms % 60_000) / 1_000;
|
||||
let cs = (ms % 1_000) / 10;
|
||||
// [mm:ss.cc] matches parseLrc's /\d+(?:\.\d*)?/ regex
|
||||
Some(format!("[{:02}:{:02}.{:02}]{}\n", mins, secs, cs, t))
|
||||
})
|
||||
.collect();
|
||||
if !lrc.is_empty() {
|
||||
return Some(lrc.trim_end().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. USLT — unsynchronized lyrics, plain-text fallback.
|
||||
for frame in tag.frames() {
|
||||
if frame.id() != "USLT" {
|
||||
continue;
|
||||
}
|
||||
if let Content::Lyrics(uslt) = frame.content() {
|
||||
let text = uslt.text.trim();
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return None; // MPEG file but no usable lyrics found
|
||||
}
|
||||
|
||||
// ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ────────────────────
|
||||
// Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field.
|
||||
// It is not a known lofty ItemKey, so access it via ItemKey::Unknown.
|
||||
let tagged = probe.read().ok()?;
|
||||
for tag in tagged.tags() {
|
||||
if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) {
|
||||
let lrc = lrc.trim();
|
||||
if !lrc.is_empty() {
|
||||
return Some(lrc.to_owned());
|
||||
}
|
||||
}
|
||||
if let Some(plain) = tag.get_string(&ItemKey::Lyrics) {
|
||||
let plain = plain.trim();
|
||||
if !plain.is_empty() {
|
||||
return Some(plain.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Opens a directory in the OS file manager (Explorer / Finder / Nautilus).
|
||||
/// Uses platform-specific process spawning — tauri-plugin-shell's open() only
|
||||
/// allows https:// URLs per the capability scope and fails silently for paths.
|
||||
#[tauri::command]
|
||||
fn open_folder(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer.exe")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Progress payload emitted to the frontend during a ZIP download.
|
||||
/// `total` is `None` when the server doesn't send a `Content-Length` header
|
||||
/// (Navidrome on-the-fly ZIPs).
|
||||
@@ -1138,6 +1562,24 @@ pub fn run() {
|
||||
app.manage(MprisControls::new(maybe_controls));
|
||||
}
|
||||
|
||||
// ── Windows Taskbar Thumbnail Toolbar ────────────────────────
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
if let Ok(hwnd) = w.hwnd() {
|
||||
taskbar_win::init(app.handle(), hwnd.0 as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio device-change watcher ───────────────────────────────
|
||||
{
|
||||
use tauri::Manager;
|
||||
let engine = app.state::<audio::AudioEngine>();
|
||||
audio::start_device_watcher(&engine, app.handle().clone());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -1196,6 +1638,8 @@ pub fn run() {
|
||||
search_radio_browser,
|
||||
get_top_radio_stations,
|
||||
fetch_url_bytes,
|
||||
fetch_json_url,
|
||||
fetch_icy_metadata,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
@@ -1206,6 +1650,13 @@ pub fn run() {
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
check_arch_linux,
|
||||
download_update,
|
||||
open_folder,
|
||||
get_embedded_lyrics,
|
||||
fetch_netease_lyrics,
|
||||
#[cfg(target_os = "windows")]
|
||||
taskbar_win::update_taskbar_icon,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Windows Taskbar Thumbnail Toolbar (ITaskbarList3::ThumbBarAddButtons).
|
||||
//!
|
||||
//! Adds Prev / Play-Pause / Next buttons to the taskbar thumbnail preview.
|
||||
//! Button clicks are intercepted via SetWindowSubclass and routed to the same
|
||||
//! `media:prev`, `media:play-pause`, `media:next` events as souvlaki / tray.
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
},
|
||||
UI::{
|
||||
Shell::{
|
||||
DefSubclassProc, ITaskbarList3, RemoveWindowSubclass, SetWindowSubclass,
|
||||
TaskbarList, THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBN_CLICKED,
|
||||
THB_FLAGS, THB_ICON, THB_TOOLTIP,
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
||||
WM_COMMAND, WM_NCDESTROY,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Embedded ICO assets ──────────────────────────────────────────────────────
|
||||
|
||||
static PREV_ICO: &[u8] = include_bytes!("../icons/windows/prev.ico");
|
||||
static PLAY_ICO: &[u8] = include_bytes!("../icons/windows/play.ico");
|
||||
static PAUSE_ICO: &[u8] = include_bytes!("../icons/windows/pause.ico");
|
||||
static NEXT_ICO: &[u8] = include_bytes!("../icons/windows/next.ico");
|
||||
|
||||
// Button IDs — arbitrary u32 values, must fit in WPARAM low-word.
|
||||
const BTN_PREV: u32 = 0xE001;
|
||||
const BTN_PLAY: u32 = 0xE002;
|
||||
const BTN_NEXT: u32 = 0xE003;
|
||||
|
||||
// Unique subclass ID.
|
||||
const SUBCLASS_ID: usize = 0xC0DE_7A8B;
|
||||
|
||||
// Raw pointers kept as atomics so `update_taskbar_icon` can reach the
|
||||
// COM object and icons without managed state.
|
||||
static TASKBAR_PTR: AtomicIsize = AtomicIsize::new(0);
|
||||
static HWND_VAL: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// All four HICONs stored for WM_NCDESTROY cleanup and play/pause swapping.
|
||||
static HICON_PREV: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// ── ICO resource loader ──────────────────────────────────────────────────────
|
||||
|
||||
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
|
||||
///
|
||||
/// Parses the ICO directory to pick the entry with the highest bit depth
|
||||
/// (32 bpp = true-colour + alpha), then passes the image bits directly to
|
||||
/// `CreateIconFromResourceEx`.
|
||||
///
|
||||
/// Note: `LookupIconIdFromDirectoryEx` operates on Win32 *resource* group-icon
|
||||
/// format (GRPICONDIR), not raw `.ico` files, so we parse the ICO header ourselves.
|
||||
unsafe fn load_icon_from_memory(bytes: &[u8]) -> HICON {
|
||||
// ICO file layout:
|
||||
// ICONDIR : reserved(2) + type(2) + count(2)
|
||||
// ICONDIRENTRY[] : width(1) height(1) color_count(1) reserved(1)
|
||||
// planes(2) bit_count(2) bytes_in_res(4) image_offset(4)
|
||||
if bytes.len() < 6 {
|
||||
return HICON::default();
|
||||
}
|
||||
let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
|
||||
if count == 0 || bytes.len() < 6 + count * 16 {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
// Pick the entry with the highest bit depth; 32 bpp carries alpha.
|
||||
let mut best_idx = 0usize;
|
||||
let mut best_bpp = 0u16;
|
||||
for i in 0..count {
|
||||
let base = 6 + i * 16;
|
||||
let bpp = u16::from_le_bytes([bytes[base + 6], bytes[base + 7]]);
|
||||
if bpp >= best_bpp {
|
||||
best_bpp = bpp;
|
||||
best_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = &bytes[6 + best_idx * 16..];
|
||||
let img_size = u32::from_le_bytes(entry[8..12].try_into().unwrap_or([0; 4]));
|
||||
let img_offset = u32::from_le_bytes(entry[12..16].try_into().unwrap_or([0; 4])) as usize;
|
||||
|
||||
if img_size == 0 || img_offset + img_size as usize > bytes.len() {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
CreateIconFromResourceEx(
|
||||
&bytes[img_offset..img_offset + img_size as usize],
|
||||
true, // fIcon = TRUE
|
||||
0x0003_0000, // dwVer = 3.0 (required by the API)
|
||||
0, 0, // cxDesired / cyDesired — 0 lets the system choose
|
||||
LR_DEFAULTCOLOR,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Button descriptors ───────────────────────────────────────────────────────
|
||||
|
||||
fn copy_tip(dest: &mut [u16], src: &str) {
|
||||
let wide: Vec<u16> = src.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let len = wide.len().min(dest.len());
|
||||
dest[..len].copy_from_slice(&wide[..len]);
|
||||
}
|
||||
|
||||
unsafe fn make_buttons(
|
||||
h_prev: HICON,
|
||||
h_play: HICON,
|
||||
h_next: HICON,
|
||||
) -> [THUMBBUTTON; 3] {
|
||||
let mask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
||||
let flags = THUMBBUTTONFLAGS(0); // THBF_ENABLED
|
||||
|
||||
let mut prev = THUMBBUTTON::default();
|
||||
prev.dwMask = mask; prev.iId = BTN_PREV;
|
||||
prev.hIcon = h_prev; prev.dwFlags = flags;
|
||||
copy_tip(&mut prev.szTip, "Previous");
|
||||
|
||||
let mut play = THUMBBUTTON::default();
|
||||
play.dwMask = mask; play.iId = BTN_PLAY;
|
||||
play.hIcon = h_play; play.dwFlags = flags;
|
||||
copy_tip(&mut play.szTip, "Play");
|
||||
|
||||
let mut next = THUMBBUTTON::default();
|
||||
next.dwMask = mask; next.iId = BTN_NEXT;
|
||||
next.hIcon = h_next; next.dwFlags = flags;
|
||||
copy_tip(&mut next.szTip, "Next");
|
||||
|
||||
[prev, play, next]
|
||||
}
|
||||
|
||||
// ── WndProc subclass ─────────────────────────────────────────────────────────
|
||||
|
||||
struct SubclassData {
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_uid: usize,
|
||||
data: usize,
|
||||
) -> LRESULT {
|
||||
if msg == WM_COMMAND {
|
||||
let hi = (wparam.0 >> 16) as u32;
|
||||
let lo = (wparam.0 & 0xFFFF) as u32;
|
||||
if hi == THBN_CLICKED as u32 {
|
||||
if data != 0 {
|
||||
let state = &*(data as *const SubclassData);
|
||||
let _ = match lo {
|
||||
x if x == BTN_PREV => state.app.emit("media:prev", ()),
|
||||
x if x == BTN_PLAY => state.app.emit("media:play-pause", ()),
|
||||
x if x == BTN_NEXT => state.app.emit("media:next", ()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
return LRESULT(0);
|
||||
}
|
||||
}
|
||||
|
||||
if msg == WM_NCDESTROY {
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
|
||||
if data != 0 {
|
||||
drop(Box::from_raw(data as *mut SubclassData));
|
||||
}
|
||||
let raw = TASKBAR_PTR.swap(0, Ordering::SeqCst);
|
||||
if raw != 0 {
|
||||
drop(Box::from_raw(raw as *mut ITaskbarList3));
|
||||
}
|
||||
HWND_VAL.store(0, Ordering::SeqCst);
|
||||
// Destroy all stored HICONs.
|
||||
for cell in [&HICON_PREV, &HICON_PLAY, &HICON_PAUSE, &HICON_NEXT] {
|
||||
let h = cell.swap(0, Ordering::SeqCst);
|
||||
if h != 0 { let _ = DestroyIcon(HICON(h as *mut _)); }
|
||||
}
|
||||
}
|
||||
|
||||
DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
|
||||
// ── Public init ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
let taskbar: ITaskbarList3 = match CoCreateInstance(
|
||||
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
|
||||
};
|
||||
|
||||
if let Err(e) = taskbar.HrInit() {
|
||||
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let h_prev = load_icon_from_memory(PREV_ICO);
|
||||
let h_play = load_icon_from_memory(PLAY_ICO);
|
||||
let h_pause = load_icon_from_memory(PAUSE_ICO);
|
||||
let h_next = load_icon_from_memory(NEXT_ICO);
|
||||
|
||||
// Store all HICONs for cleanup and play/pause swapping.
|
||||
HICON_PREV .store(h_prev .0 as isize, Ordering::SeqCst);
|
||||
HICON_PLAY .store(h_play .0 as isize, Ordering::SeqCst);
|
||||
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
|
||||
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
|
||||
|
||||
let mut buttons = make_buttons(h_prev, h_play, h_next);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
|
||||
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = Box::into_raw(Box::new(taskbar));
|
||||
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
|
||||
HWND_VAL .store(hwnd_raw, Ordering::SeqCst);
|
||||
|
||||
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
||||
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
|
||||
eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
|
||||
drop(Box::from_raw(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri command ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_taskbar_icon(is_playing: bool) {
|
||||
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
|
||||
let hwnd_raw = HWND_VAL .load(Ordering::SeqCst);
|
||||
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
|
||||
|
||||
let icon_raw = if is_playing {
|
||||
HICON_PAUSE.load(Ordering::SeqCst)
|
||||
} else {
|
||||
HICON_PLAY.load(Ordering::SeqCst)
|
||||
};
|
||||
if icon_raw == 0 { return; }
|
||||
|
||||
unsafe {
|
||||
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
let mut btn = THUMBBUTTON::default();
|
||||
btn.dwMask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
||||
btn.iId = BTN_PLAY;
|
||||
btn.hIcon = HICON(icon_raw as *mut _);
|
||||
btn.dwFlags = THUMBBUTTONFLAGS(0);
|
||||
copy_tip(&mut btn.szTip, if is_playing { "Pause" } else { "Play" });
|
||||
|
||||
let mut btns = [btn];
|
||||
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.6",
|
||||
"version": "1.34.8",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+37
-1
@@ -88,9 +88,13 @@ function AppShell() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
const win = getCurrentWindow();
|
||||
// Check initial state (e.g. app launched maximised / already fullscreen).
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
let unlisten: (() => void) | undefined;
|
||||
// onResized fires on every size change, including fullscreen enter/exit on
|
||||
// all platforms. We re-query isFullscreen() rather than inferring from
|
||||
// the size so the flag is always accurate regardless of platform quirks.
|
||||
win.onResized(() => {
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
}).then(u => { unlisten = u; });
|
||||
@@ -299,6 +303,7 @@ function AppShell() {
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
|
||||
data-fullscreen={isWindowFullscreen || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
@@ -408,6 +413,28 @@ function TauriEventBridge() {
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
|
||||
// The Rust device-watcher has already reopened the stream on the new device
|
||||
// and dropped the old Sink, so we just need to restart playback.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-changed', () => {
|
||||
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
const pos = currentTime;
|
||||
const dur = currentTrack.duration || 1;
|
||||
playTrack(currentTrack);
|
||||
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
|
||||
} else {
|
||||
// Paused: clear warm-pause flag so the next resume uses the cold path
|
||||
// (audio_play + seek) which creates a new Sink on the new device.
|
||||
resetAudioPause();
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Sync tray-icon visibility with the user's stored setting.
|
||||
// Runs once on mount (initial sync) and again whenever the setting changes.
|
||||
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
|
||||
@@ -530,6 +557,7 @@ export default function App() {
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const setUiScale = useFontStore(s => s.setUiScale);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -540,6 +568,14 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
|
||||
// Reset any stored non-100% value so users aren't stuck at a broken scale.
|
||||
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
|
||||
useEffect(() => {
|
||||
if (uiScale !== 1.0) setUiScale(1.0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = String(uiScale);
|
||||
}, [uiScale]);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ─── AzuraCast API types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AzuraCastSong {
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string;
|
||||
art?: string;
|
||||
text?: string; // "Artist - Title" combined
|
||||
}
|
||||
|
||||
export interface AzuraCastNowPlayingTrack {
|
||||
song: AzuraCastSong;
|
||||
duration: number; // seconds
|
||||
elapsed: number; // seconds played so far
|
||||
remaining: number; // seconds remaining
|
||||
played_at?: number;
|
||||
}
|
||||
|
||||
export interface AzuraCastListeners {
|
||||
current: number;
|
||||
unique?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface AzuraCastNowPlaying {
|
||||
now_playing: AzuraCastNowPlayingTrack;
|
||||
playing_next?: { song: AzuraCastSong } | null;
|
||||
song_history: Array<{ song: AzuraCastSong; played_at?: number }>;
|
||||
listeners: AzuraCastListeners;
|
||||
station?: { name: string; shortcode: string };
|
||||
}
|
||||
|
||||
// ─── Detection helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to derive an AzuraCast NowPlaying API URL from a stream URL.
|
||||
*
|
||||
* AzuraCast stream URLs follow the pattern:
|
||||
* https://<host>/listen/<shortcode>/<bitrate>.<ext>
|
||||
*
|
||||
* Returns the candidate API URL or `null` if the pattern doesn't match.
|
||||
*/
|
||||
export function guessAzuraCastApiUrl(streamUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(streamUrl);
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
// Expect: ['listen', '<shortcode>', '<file>']
|
||||
if (parts.length >= 2 && parts[0] === 'listen') {
|
||||
const shortcode = parts[1];
|
||||
return `${u.origin}/api/nowplaying/${shortcode}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a homepage URL itself looks like an AzuraCast NowPlaying
|
||||
* API endpoint and return the canonical URL to use.
|
||||
*
|
||||
* Accepts:
|
||||
* - https://<host>/api/nowplaying → all stations, we use as-is
|
||||
* - https://<host>/api/nowplaying/<shortcode> → single station, use as-is
|
||||
*/
|
||||
export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(homepageUrl);
|
||||
if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) {
|
||||
return homepageUrl;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via
|
||||
* the Rust backend). Returns `null` if the request fails or the response
|
||||
* does not look like a valid AzuraCast payload.
|
||||
*
|
||||
* When the API URL points to the `/api/nowplaying` (array) endpoint, the
|
||||
* first item in the array is returned. Otherwise the single-object form is
|
||||
* used directly.
|
||||
*/
|
||||
export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise<AzuraCastNowPlaying | null> {
|
||||
try {
|
||||
const raw: string = await invoke('fetch_json_url', { url: apiUrl });
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
// If the response is an array (all-stations endpoint), take the first item.
|
||||
const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
|
||||
const np = obj as Record<string, unknown>;
|
||||
// Minimal validation: must have `now_playing` with a `song` inside.
|
||||
if (
|
||||
np.now_playing &&
|
||||
typeof np.now_playing === 'object' &&
|
||||
(np.now_playing as Record<string, unknown>).song
|
||||
) {
|
||||
return np as unknown as AzuraCastNowPlaying;
|
||||
}
|
||||
} catch {
|
||||
// Network error, JSON parse error, etc.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+3
-1
@@ -36,7 +36,9 @@ export async function fetchLyrics(
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
|
||||
// \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches.
|
||||
// parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly.
|
||||
const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/);
|
||||
if (!match) continue;
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseFloat(match[2]);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
|
||||
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+358
-11
@@ -3,6 +3,11 @@ import md5 from 'md5';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { version } from '../../package.json';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
|
||||
// ─── Secure random salt ────────────────────────────────────────
|
||||
function secureRandomSalt(): string {
|
||||
@@ -265,8 +270,14 @@ export async function ping(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
|
||||
/** Test a connection with explicit credentials — does NOT depend on store state. */
|
||||
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
|
||||
export async function pingWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
@@ -277,12 +288,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
return data?.status === 'ok';
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function restBaseFromUrl(serverUrl: string): string {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
return `${base}/rest`;
|
||||
}
|
||||
|
||||
async function apiWithCredentials<T>(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
|
||||
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||
|
||||
/**
|
||||
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
|
||||
* Does not pass `musicFolderId` — probes the whole library as seen by the account.
|
||||
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
|
||||
*/
|
||||
export async function probeInstantMixWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<InstantMixProbeResult> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getRandomSongs.view',
|
||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||
12000,
|
||||
);
|
||||
const raw = data.randomSongs?.song;
|
||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
if (songs.length === 0) return 'skipped';
|
||||
|
||||
let anyError = false;
|
||||
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
|
||||
try {
|
||||
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getSimilarSongs.view',
|
||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||
12000,
|
||||
);
|
||||
const sRaw = simData.similarSongs?.song;
|
||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||
if (list.some(s => s.id !== song.id)) return 'ok';
|
||||
} catch {
|
||||
anyError = true;
|
||||
}
|
||||
}
|
||||
return anyError ? 'error' : 'empty';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
): void {
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'random',
|
||||
@@ -309,6 +416,68 @@ export async function getAlbumList(
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
|
||||
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
|
||||
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
|
||||
*/
|
||||
let scopedLibraryAlbumIdCache: {
|
||||
serverId: string;
|
||||
folderId: string;
|
||||
filterVersion: number;
|
||||
ids: Set<string>;
|
||||
} | null = null;
|
||||
|
||||
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
|
||||
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const folder = musicLibraryFilterByServer[activeServerId];
|
||||
if (folder === undefined || folder === 'all') {
|
||||
scopedLibraryAlbumIdCache = null;
|
||||
return null;
|
||||
}
|
||||
const hit = scopedLibraryAlbumIdCache;
|
||||
if (
|
||||
hit &&
|
||||
hit.serverId === activeServerId &&
|
||||
hit.folderId === folder &&
|
||||
hit.filterVersion === musicLibraryFilterVersion
|
||||
) {
|
||||
return hit.ids;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pageSize = 500;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
|
||||
for (const a of albums) ids.add(a.id);
|
||||
if (albums.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
if (offset > 500_000) break;
|
||||
}
|
||||
scopedLibraryAlbumIdCache = {
|
||||
serverId: activeServerId,
|
||||
folderId: folder,
|
||||
filterVersion: musicLibraryFilterVersion,
|
||||
ids,
|
||||
};
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
||||
const allowed = await albumIdsInActiveLibraryScope();
|
||||
if (!allowed || allowed.size === 0) return songs;
|
||||
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
||||
}
|
||||
|
||||
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
||||
function similarSongsRequestCount(desired: number): number {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||
if (f === undefined || f === 'all') return desired;
|
||||
return Math.min(300, Math.max(desired, desired * 4));
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
||||
if (genre) params.genre = genre;
|
||||
@@ -425,6 +594,157 @@ export async function prefetchAlbumUserRatings(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
|
||||
export interface StatisticsLibraryAggregates {
|
||||
playtimeSec: number;
|
||||
albumsCounted: number;
|
||||
songsCounted: number;
|
||||
capped: boolean;
|
||||
genres: SubsonicGenre[];
|
||||
}
|
||||
|
||||
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
|
||||
function statisticsPageCacheKey(prefix: string): string | null {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
|
||||
const folderPart = folder === 'all' ? 'all' : folder;
|
||||
return `${prefix}:${activeServerId}:${folderPart}`;
|
||||
}
|
||||
|
||||
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
|
||||
|
||||
/**
|
||||
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
|
||||
* 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch).
|
||||
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
|
||||
*/
|
||||
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
|
||||
const key = statisticsPageCacheKey('statsAgg');
|
||||
if (key) {
|
||||
const hit = statisticsAggregatesCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
|
||||
let playtimeSec = 0;
|
||||
let albumsCounted = 0;
|
||||
let songsCounted = 0;
|
||||
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
|
||||
const pageSize = 500;
|
||||
const maxPages = 10;
|
||||
let capped = false;
|
||||
let offset = 0;
|
||||
let nextPage = getAlbumList('newest', pageSize, 0);
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
try {
|
||||
const albums = await nextPage;
|
||||
for (const a of albums) {
|
||||
playtimeSec += a.duration ?? 0;
|
||||
albumsCounted += 1;
|
||||
const sc = a.songCount ?? 0;
|
||||
songsCounted += sc;
|
||||
const label = (a.genre?.trim()) ? a.genre.trim() : '';
|
||||
let g = genreAgg.get(label);
|
||||
if (!g) {
|
||||
g = { songCount: 0, albumCount: 0 };
|
||||
genreAgg.set(label, g);
|
||||
}
|
||||
g.songCount += sc;
|
||||
g.albumCount += 1;
|
||||
}
|
||||
if (albums.length < pageSize) break;
|
||||
if (page === maxPages - 1) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
offset += pageSize;
|
||||
nextPage = getAlbumList('newest', pageSize, offset);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const genres: SubsonicGenre[] = [...genreAgg.entries()]
|
||||
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
|
||||
.sort((a, b) => b.songCount - a.songCount);
|
||||
|
||||
const result: StatisticsLibraryAggregates = {
|
||||
playtimeSec,
|
||||
albumsCounted,
|
||||
songsCounted,
|
||||
capped,
|
||||
genres,
|
||||
};
|
||||
if (key) {
|
||||
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Recent / frequent / highest album strips + artist count for Statistics. */
|
||||
export interface StatisticsOverviewData {
|
||||
recent: SubsonicAlbum[];
|
||||
frequent: SubsonicAlbum[];
|
||||
highest: SubsonicAlbum[];
|
||||
artistCount: number;
|
||||
}
|
||||
|
||||
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
|
||||
|
||||
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
|
||||
const key = statisticsPageCacheKey('statsOverview');
|
||||
if (key) {
|
||||
const hit = statisticsOverviewCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
const [recent, frequent, highest, artists] = await Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
|
||||
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
|
||||
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
|
||||
getArtists().catch(() => [] as SubsonicArtist[]),
|
||||
]);
|
||||
const result: StatisticsOverviewData = {
|
||||
recent,
|
||||
frequent,
|
||||
highest,
|
||||
artistCount: artists.length,
|
||||
};
|
||||
if (key) {
|
||||
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Format (suffix) histogram from a random sample for Statistics. */
|
||||
export interface StatisticsFormatSample {
|
||||
rows: { format: string; count: number }[];
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
|
||||
|
||||
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
|
||||
const key = statisticsPageCacheKey('statsFormat');
|
||||
if (key) {
|
||||
const hit = statisticsFormatCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const song of songs) {
|
||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
||||
}
|
||||
const rows = Object.entries(counts)
|
||||
.map(([format, count]) => ({ format, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
|
||||
if (key) {
|
||||
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
@@ -439,15 +759,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> {
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 });
|
||||
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
||||
const count = options?.similarArtistCount ?? 5;
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
||||
return data.artistInfo2 ?? {};
|
||||
}
|
||||
|
||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 });
|
||||
return data.topSongs?.song ?? [];
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
|
||||
const topCount = scoped ? 20 : 5;
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
|
||||
const raw = data.topSongs?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, 5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -455,8 +781,26 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
|
||||
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count });
|
||||
return data.similarSongs2?.song ?? [];
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs2?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
|
||||
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs?.song;
|
||||
if (!raw) return [];
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
const filtered = await filterSongsToActiveLibrary(list);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -856,7 +1200,10 @@ export interface SubsonicLyricLine {
|
||||
}
|
||||
|
||||
export interface SubsonicStructuredLyrics {
|
||||
issynced: boolean;
|
||||
/** OpenSubsonic spec field name (Navidrome ≥ 0.50.0 / any OpenSubsonic server). */
|
||||
synced?: boolean;
|
||||
/** Legacy / alternate casing used by some older Subsonic-compatible servers. */
|
||||
issynced?: boolean;
|
||||
lang?: string;
|
||||
offset?: number;
|
||||
displayArtist?: string;
|
||||
@@ -878,7 +1225,7 @@ export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredL
|
||||
);
|
||||
const list = data.lyricsList?.structuredLyrics;
|
||||
if (!list || list.length === 0) return null;
|
||||
return list.find(l => l.issynced) ?? list[0];
|
||||
return list.find(l => l.synced || l.issynced) ?? list[0];
|
||||
} catch {
|
||||
// Server doesn't support the endpoint or track has no embedded lyrics
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -243,7 +243,7 @@ export default function AlbumHeader({
|
||||
aria-label={t('albumDetail.artistBio')}
|
||||
data-tooltip={t('albumDetail.artistBio')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
<Highlighter size={16} />
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
@@ -313,7 +313,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
|
||||
@@ -227,18 +227,27 @@ export default function AlbumTrackList({
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'artist':
|
||||
case 'artist': {
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.artist }];
|
||||
return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>
|
||||
{song.artist}
|
||||
</span>
|
||||
{artistRefs.map((a, i) => (
|
||||
<React.Fragment key={a.id ?? a.name ?? i}>
|
||||
{i > 0 && <span className="track-artist-sep"> · </span>}
|
||||
<span
|
||||
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: a.id ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
|
||||
>
|
||||
{a.name ?? song.artist}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'favorite':
|
||||
return (
|
||||
<div key="favorite" className="track-star-cell">
|
||||
|
||||
+318
-42
@@ -1,9 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
const SKIP_KEY = 'psysonic_skipped_update_version';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
@@ -16,57 +22,327 @@ function isNewer(a: string, b: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Minimal inline-markdown renderer (bold, italic, code)
|
||||
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
|
||||
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (!part) return null;
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('*') && part.endsWith('*'))
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChangelog(body: string) {
|
||||
return body.split('\n').map((line, i) => {
|
||||
if (line.startsWith('### '))
|
||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||
if (line.startsWith('#### '))
|
||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||
if (line.startsWith('## '))
|
||||
return null; // skip nested release headers in body
|
||||
if (line.startsWith('- '))
|
||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||
if (line.trim() === '') return null;
|
||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||
});
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ReleaseData {
|
||||
version: string;
|
||||
tag: string;
|
||||
body: string;
|
||||
assets: GithubAsset[];
|
||||
}
|
||||
|
||||
function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
||||
if (IS_WINDOWS) {
|
||||
return assets.find(a => a.name.endsWith('-setup.exe'))
|
||||
?? assets.find(a => a.name.endsWith('.exe'));
|
||||
}
|
||||
if (IS_MACOS) {
|
||||
// Prefer Apple Silicon, fall back to Intel
|
||||
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
||||
?? assets.find(a => a.name.endsWith('.dmg'));
|
||||
}
|
||||
if (IS_LINUX) {
|
||||
// AppImage > deb > rpm
|
||||
return assets.find(a => a.name.endsWith('.AppImage'))
|
||||
?? assets.find(a => a.name.endsWith('.deb'))
|
||||
?? assets.find(a => a.name.endsWith('.rpm'));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [newVersion, setNewVersion] = useState<string | null>(null);
|
||||
const [release, setRelease] = useState<ReleaseData | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [isArch, setIsArch] = useState(false);
|
||||
const [dlState, setDlState] = useState<DlState>('idle');
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
const version = tag.replace(/^[^0-9]*/, '');
|
||||
if (!version) return;
|
||||
if (!preview) {
|
||||
if (!isNewer(version, currentVersion)) return;
|
||||
const skipped = localStorage.getItem(SKIP_KEY);
|
||||
if (skipped === version) return;
|
||||
}
|
||||
setDismissed(false);
|
||||
setDlState('idle');
|
||||
setRelease({
|
||||
version,
|
||||
tag,
|
||||
body: (data.body ?? '').trim(),
|
||||
assets: data.assets ?? [],
|
||||
});
|
||||
if (IS_LINUX) {
|
||||
const arch = await invoke<boolean>('check_arch_linux');
|
||||
setIsArch(arch);
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setNewVersion(tag.replace(/^[^0-9]*/, ''));
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
}, 4000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
||||
|
||||
const handler = () => fetchRelease(true);
|
||||
window.addEventListener('psysonic:preview-update', handler);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('psysonic:preview-update', handler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!newVersion || dismissed) return null;
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => { unlistenRef.current?.(); };
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
const asset = pickAsset(release.assets);
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!asset) return;
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: asset.size });
|
||||
setDlError('');
|
||||
|
||||
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
||||
'update:download:progress',
|
||||
e => {
|
||||
setDlProgress({
|
||||
bytes: e.payload.bytes,
|
||||
total: e.payload.total ?? asset.size,
|
||||
});
|
||||
}
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
try {
|
||||
const finalPath = await invoke<string>('download_update', {
|
||||
url: asset.browser_download_url,
|
||||
filename: asset.name,
|
||||
});
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlPath(finalPath);
|
||||
setDlState('done');
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowFolder = async () => {
|
||||
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
||||
// local paths are blocked and fail silently. Delegate to Rust instead.
|
||||
const dir = await dirname(dlPath);
|
||||
await invoke('open_folder', { path: dir });
|
||||
};
|
||||
|
||||
const pct = dlProgress.total > 0
|
||||
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
||||
: 0;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
|
||||
<div
|
||||
className="eq-popup update-modal"
|
||||
style={{ zIndex: 3001 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="eq-popup-header update-modal-header">
|
||||
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
|
||||
<span className="update-modal-versions">
|
||||
v{currentVersion} → <strong>v{release.version}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="app-updater-dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
data-tooltip={t('common.updaterRemindBtn')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body: changelog + download area — single overflow container */}
|
||||
<div className="update-modal-body">
|
||||
{/* Collapsible Changelog */}
|
||||
{release.body && (
|
||||
<div className="update-modal-changelog">
|
||||
<button
|
||||
type="button"
|
||||
className="update-modal-changelog-toggle"
|
||||
onClick={() => setChangelogOpen(v => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transform: changelogOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{t('common.updaterChangelog')}
|
||||
</button>
|
||||
{changelogOpen && (
|
||||
<div className="update-modal-changelog-body">
|
||||
{renderChangelog(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download / AUR area */}
|
||||
<div className="update-modal-download-area">
|
||||
{showAurHint ? (
|
||||
<div className="update-modal-aur">
|
||||
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">{asset.name}</span>
|
||||
<span className="update-modal-asset-size">{fmtBytes(asset.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
|
||||
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
|
||||
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
|
||||
<FolderOpen size={14} />
|
||||
{t('common.updaterShowFolder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="update-modal-asset-none">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
|
||||
>
|
||||
{t('common.updaterOpenGitHub')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="update-modal-footer">
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{!showAurHint && asset && dlState === 'idle' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
|
||||
<div className="app-updater-actions">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
className="app-updater-btn-secondary"
|
||||
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
|
||||
>
|
||||
{t('common.updaterWebsite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
+105
-18
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
@@ -14,6 +15,7 @@ import { join } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -23,6 +25,16 @@ function sanitizeFilename(name: string): string {
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Add-to-Playlist submenu ───────────────────────────────────────
|
||||
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -165,8 +177,26 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
contextMenu: s.contextMenu,
|
||||
closeContextMenu: s.closeContextMenu,
|
||||
playTrack: s.playTrack,
|
||||
enqueue: s.enqueue,
|
||||
queue: s.queue,
|
||||
currentTrack: s.currentTrack,
|
||||
removeTrack: s.removeTrack,
|
||||
lastfmLovedCache: s.lastfmLovedCache,
|
||||
setLastfmLovedForSong: s.setLastfmLovedForSong,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
openSongInfo: s.openSongInfo,
|
||||
userRatingOverrides: s.userRatingOverrides,
|
||||
setUserRatingOverride: s.setUserRatingOverride,
|
||||
}))
|
||||
);
|
||||
const auth = useAuthStore();
|
||||
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -221,12 +251,19 @@ export default function ContextMenu() {
|
||||
}
|
||||
// Load radio queue in background — enqueueRadio replaces any pending radio
|
||||
// tracks so clicking "Start Radio" again never stacks duplicate batches.
|
||||
// Shuffle so the follow-up tracks feel fresh instead of always being the
|
||||
// same "Top 5" in the same order every time.
|
||||
try {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedTrack.id)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
// Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile —
|
||||
// otherwise this feels the same as Instant Mix (track-based similar only).
|
||||
const topTracks = shuffleArray(
|
||||
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
|
||||
);
|
||||
const similarTracks = shuffleArray(
|
||||
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
|
||||
);
|
||||
const radioTracks = [...topTracks, ...similarTracks];
|
||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
@@ -238,11 +275,17 @@ export default function ContextMenu() {
|
||||
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
|
||||
try {
|
||||
const top = await getTopSongs(artistName);
|
||||
const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
// Shuffle so each Radio session starts from a different track rather
|
||||
// than always kicking off with the #1 most-played song.
|
||||
const topTracks = shuffleArray(
|
||||
top.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
);
|
||||
if (topTracks.length === 0) {
|
||||
// No local top songs — fall back to waiting for similar tracks
|
||||
const similar = await similarPromise;
|
||||
const fallback = similar.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
const fallback = shuffleArray(
|
||||
similar.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
);
|
||||
if (fallback.length === 0) return;
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
@@ -253,22 +296,29 @@ export default function ContextMenu() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Start playback immediately from top songs
|
||||
// Start playback from the first shuffled top track only.
|
||||
// No other tracks are queued yet — positions 2+ will be filled
|
||||
// exclusively by the similar-songs result below.
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(topTracks, artistId);
|
||||
state.enqueueRadio([topTracks[0]], artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(topTracks[0], topTracks);
|
||||
playTrack(topTracks[0], [topTracks[0]]);
|
||||
}
|
||||
// Enrich with similar tracks in the background
|
||||
// Populate positions 2+ from similar songs only — never from the
|
||||
// remaining top tracks. Mixing in topTracks.slice(1) meant that when
|
||||
// getSimilarSongs2 returned nothing (no Last.fm, small library, etc.)
|
||||
// the queue fell back to the same top-4 the user just heard.
|
||||
// If similarTracks is also empty, the proactive top-up in next()
|
||||
// will refill the queue when the first track nears its end.
|
||||
similarPromise.then(similar => {
|
||||
const similarTracks = similar
|
||||
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
.filter(t => !topTracks.some(top => top.id === t.id));
|
||||
const similarTracks = shuffleArray(
|
||||
similar
|
||||
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
.filter(t => t.id !== topTracks[0].id)
|
||||
);
|
||||
if (similarTracks.length === 0) return;
|
||||
// Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them
|
||||
// together with the new similar tracks rather than losing them.
|
||||
const { queue, queueIndex } = usePlayerStore.getState();
|
||||
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
|
||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
||||
@@ -279,6 +329,33 @@ export default function ContextMenu() {
|
||||
}
|
||||
};
|
||||
|
||||
const startInstantMix = async (song: Track) => {
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack?.id === song.id) {
|
||||
if (!state.isPlaying) state.resume();
|
||||
} else {
|
||||
playTrack(song, [song]);
|
||||
}
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
try {
|
||||
const similar = await getSimilarSongs(song.id, 50);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
||||
const shuffled = shuffleArray(
|
||||
similar
|
||||
.filter(s => s.id !== song.id)
|
||||
.map(s => ({ ...songToTrack(s), radioAdded: true as const }))
|
||||
);
|
||||
if (shuffled.length > 0) {
|
||||
const aid = song.artistId?.trim() || undefined;
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Instant mix failed', e);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
|
||||
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
@@ -362,6 +439,11 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
{audiomuseNavidromeEnabled && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(song.id, song.starred);
|
||||
setStarredOverride(song.id, !starred);
|
||||
@@ -512,6 +594,11 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
{audiomuseNavidromeEnabled && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||
<StarRating
|
||||
|
||||
@@ -192,34 +192,50 @@ const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
|
||||
// ─── Full-width seekbar — imperative DOM updates, zero React re-renders on tick ─
|
||||
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const timeRef = useRef<HTMLSpanElement>(null);
|
||||
const playedRef = useRef<HTMLDivElement>(null);
|
||||
const bufRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const s = usePlayerStore.getState();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
return usePlayerStore.subscribe(state => {
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
||||
[seek]
|
||||
);
|
||||
|
||||
const pct = progress * 100;
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
|
||||
return (
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span ref={timeRef} />
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<div className="fs-seekbar">
|
||||
<div className="fs-seekbar-bg" />
|
||||
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
|
||||
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
|
||||
<div className="fs-seekbar-buf" ref={bufRef} />
|
||||
<div className="fs-seekbar-played" ref={playedRef} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
defaultValue={0}
|
||||
onChange={handleSeek}
|
||||
aria-label="seek"
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,6 @@ function HeroBg({ url }: { url: string }) {
|
||||
style={{
|
||||
backgroundImage: `url(${layer.url})`,
|
||||
opacity: layer.visible ? 1 : 0,
|
||||
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
|
||||
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -126,7 +126,7 @@ export default function LiveSearch() {
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('search.advanced')}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
<TextSearch size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
? t('player.lyricsSourceServer')
|
||||
: source === 'lrclib'
|
||||
? t('player.lyricsSourceLrclib')
|
||||
: null;
|
||||
: source === 'netease'
|
||||
? t('player.lyricsSourceNetease')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
@@ -16,6 +17,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -24,6 +26,21 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Renders the playback clock without ever causing PlayerBar to re-render.
|
||||
// Updates the DOM directly via an imperative store subscription.
|
||||
const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
if (spanRef.current) {
|
||||
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime);
|
||||
}
|
||||
return usePlayerStore.subscribe(state => {
|
||||
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
|
||||
});
|
||||
}, []);
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -31,19 +48,44 @@ export default function PlayerBar() {
|
||||
const [showVolPct, setShowVolPct] = useState(false);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
|
||||
const {
|
||||
currentTrack, currentRadio, isPlaying, currentTime, volume,
|
||||
currentTrack, currentRadio, isPlaying, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides, setStarredOverride,
|
||||
userRatingOverrides, setUserRatingOverride,
|
||||
} = usePlayerStore();
|
||||
} = usePlayerStore(useShallow(s => ({
|
||||
currentTrack: s.currentTrack,
|
||||
currentRadio: s.currentRadio,
|
||||
isPlaying: s.isPlaying,
|
||||
volume: s.volume,
|
||||
togglePlay: s.togglePlay,
|
||||
next: s.next,
|
||||
previous: s.previous,
|
||||
setVolume: s.setVolume,
|
||||
stop: s.stop,
|
||||
toggleRepeat: s.toggleRepeat,
|
||||
repeatMode: s.repeatMode,
|
||||
toggleFullscreen: s.toggleFullscreen,
|
||||
lastfmLoved: s.lastfmLoved,
|
||||
toggleLastfmLove: s.toggleLastfmLove,
|
||||
isQueueVisible: s.isQueueVisible,
|
||||
toggleQueue: s.toggleQueue,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
userRatingOverrides: s.userRatingOverrides,
|
||||
setUserRatingOverride: s.setUserRatingOverride,
|
||||
})));
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
@@ -129,13 +171,23 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
<MarqueeText
|
||||
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
|
||||
text={isRadio
|
||||
? (radioMeta.currentTitle
|
||||
? (radioMeta.currentArtist
|
||||
? `${radioMeta.currentArtist} — ${radioMeta.currentTitle}`
|
||||
: radioMeta.currentTitle)
|
||||
: (currentRadio?.name ?? '—'))
|
||||
: (currentTrack?.title ?? t('player.noTitle'))}
|
||||
className="player-track-name"
|
||||
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
/>
|
||||
<MarqueeText
|
||||
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
|
||||
text={isRadio
|
||||
? (radioMeta.currentTitle && currentRadio?.name
|
||||
? currentRadio.name
|
||||
: t('radio.liveStream'))
|
||||
: (currentTrack?.artist ?? '—')}
|
||||
className="player-track-artist"
|
||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
@@ -148,6 +200,11 @@ export default function PlayerBar() {
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
)}
|
||||
{isRadio && radioMeta.listeners != null && (
|
||||
<span className="player-radio-listeners">
|
||||
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
@@ -207,15 +264,32 @@ export default function PlayerBar() {
|
||||
<div className="player-waveform-section">
|
||||
{isRadio ? (
|
||||
<>
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||
</div>
|
||||
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
|
||||
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
|
||||
<>
|
||||
<span className="player-time">{formatTime(radioMeta.elapsed)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<div className="radio-progress-bar">
|
||||
<div
|
||||
className="radio-progress-fill"
|
||||
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(radioMeta.duration)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlaybackTime className="player-time" />
|
||||
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||
</div>
|
||||
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<PlaybackTime className="player-time" />
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
@@ -224,16 +298,6 @@ export default function PlayerBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lyrics Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={15} />
|
||||
</button>
|
||||
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
@@ -241,7 +305,7 @@ export default function PlayerBar() {
|
||||
aria-label="Equalizer"
|
||||
data-tooltip="Equalizer"
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
<SlidersVertical size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -33,7 +34,9 @@ function Divider() {
|
||||
|
||||
export default function SongInfoModal() {
|
||||
const { t } = useTranslation();
|
||||
const { songInfoModal, closeSongInfo } = usePlayerStore();
|
||||
const { songInfoModal, closeSongInfo } = usePlayerStore(
|
||||
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
|
||||
);
|
||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -784,6 +784,15 @@ export function SeekbarPreview({
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Architecture:
|
||||
// Static styles (waveform, bar, …): drawn directly in the Zustand subscription
|
||||
// callback — no React re-renders, no rAF loop. 2 draws/s at the 500 ms
|
||||
// Rust interval. shadowBlur + 500 canvas bars on a software-rendered
|
||||
// WebKitGTK context is too expensive for a continuous 60 fps loop.
|
||||
// Animated styles (pulsewave, particletrail, …): rAF loop at 60 fps, reads
|
||||
// refs that the subscription keeps up-to-date.
|
||||
// Drag: draws synchronously in seekToFraction for 1:1 responsiveness.
|
||||
|
||||
interface Props {
|
||||
trackId: string | undefined;
|
||||
@@ -792,35 +801,48 @@ interface Props {
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
||||
const isDragging = useRef(false);
|
||||
const animStateRef = useRef<AnimState>(makeAnimState());
|
||||
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
// Ref so the subscription callback (closed over at mount) can read the
|
||||
// current style without stale-closure issues.
|
||||
const styleRef = useRef(seekbarStyle);
|
||||
styleRef.current = seekbarStyle;
|
||||
|
||||
useEffect(() => {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
// Static styles: redraw on progress / buffered / track changes
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
useEffect(() => {
|
||||
return usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
|
||||
progressRef.current = state.progress;
|
||||
bufferedRef.current = state.buffered;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initial draw for static styles when style or track changes.
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
if (canvasRef.current) {
|
||||
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId, seekbarStyle]);
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
}, [seekbarStyle, trackId]);
|
||||
|
||||
// Animated styles: rAF loop
|
||||
// rAF loop — animated styles only.
|
||||
useEffect(() => {
|
||||
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
@@ -829,21 +851,14 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
animStateRef.current.time += 0.016;
|
||||
drawSeekbar(
|
||||
canvas,
|
||||
seekbarStyle,
|
||||
heightsRef.current,
|
||||
progressRef.current,
|
||||
bufferedRef.current,
|
||||
animStateRef.current,
|
||||
);
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Resize observer
|
||||
// Resize observer.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -859,12 +874,23 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const seekRef = useRef(seek);
|
||||
seekRef.current = seek;
|
||||
|
||||
// Seek to a 0–1 fraction: draw immediately for 1:1 responsiveness, then
|
||||
// let the store + Rust catch up asynchronously.
|
||||
const seekToFraction = (fraction: number) => {
|
||||
progressRef.current = fraction;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
|
||||
drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current);
|
||||
}
|
||||
seekRef.current(fraction);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const seekFromX = (clientX: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !trackIdRef.current) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
seekToFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
};
|
||||
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
|
||||
const onUp = () => { isDragging.current = false; };
|
||||
@@ -892,7 +918,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
onMouseDown={e => {
|
||||
isDragging.current = true;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
seekToFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
onMouseMove={e => {
|
||||
if (!trackId) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
@@ -37,8 +37,20 @@ export function useConnectionStatus() {
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
setStatus(ok ? 'connected' : 'disconnected');
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ping.ok) {
|
||||
const sid = useAuthStore.getState().activeServerId;
|
||||
if (sid) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
useAuthStore.getState().setSubsonicServerIdentity(sid, identity);
|
||||
scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity);
|
||||
}
|
||||
}
|
||||
setStatus(ping.ok ? 'connected' : 'disconnected');
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
|
||||
+66
-4
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import { fetchNeteaselyrics } from '../api/netease';
|
||||
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib';
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded';
|
||||
|
||||
export interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
@@ -20,7 +24,9 @@ export const lyricsCache = new Map<string, CachedLyrics>();
|
||||
export function parseStructuredLyrics(
|
||||
lyrics: SubsonicStructuredLyrics,
|
||||
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
|
||||
if (lyrics.issynced && lyrics.line.length > 0) {
|
||||
// Accept both `synced` (OpenSubsonic spec) and `issynced` (legacy servers).
|
||||
const isSynced = !!(lyrics.synced ?? lyrics.issynced);
|
||||
if (isSynced && lyrics.line.length > 0) {
|
||||
const lines: LrcLine[] = lyrics.line
|
||||
.filter(l => l.start !== undefined)
|
||||
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
|
||||
@@ -41,7 +47,8 @@ export interface UseLyricsResult {
|
||||
|
||||
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
const enableNeteaselyrics = useAuthStore(s => s.enableNeteaselyrics);
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
@@ -79,6 +86,37 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// For offline / hot-cached tracks we have the file locally — read SYLT /
|
||||
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
|
||||
// Fast path: both store lookups are synchronous; returns false immediately
|
||||
// for streaming tracks so it has zero impact on the normal fetch sequence.
|
||||
const fetchEmbedded = async (): Promise<boolean> => {
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const localUrl =
|
||||
useOfflineStore.getState().getLocalUrl(currentTrack.id, serverId) ??
|
||||
useHotCacheStore.getState().getLocalUrl(currentTrack.id, serverId);
|
||||
if (!localUrl) return false;
|
||||
|
||||
const prefix = 'psysonic-local://';
|
||||
const filePath = localUrl.startsWith(prefix) ? localUrl.slice(prefix.length) : null;
|
||||
if (!filePath) return false;
|
||||
|
||||
try {
|
||||
const lrcString = await invoke<string | null>('get_embedded_lyrics', { path: filePath });
|
||||
if (!lrcString) return false;
|
||||
|
||||
const lines = parseLrc(lrcString);
|
||||
const synced = lines.length > 0 ? lines : null;
|
||||
const plain = synced ? null : (lrcString.trim() || null);
|
||||
if (!synced && !plain) return false;
|
||||
|
||||
store({ syncedLines: synced, plainLyrics: plain, source: 'embedded', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServer = async (): Promise<boolean> => {
|
||||
const structured = await getLyricsBySongId(currentTrack.id);
|
||||
if (!structured) return false;
|
||||
@@ -106,7 +144,26 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
}
|
||||
};
|
||||
|
||||
const NETEASE_META = /^(作词|作曲|编曲|制作人|出版|发行|MV导演|录音|混音|监制)/;
|
||||
const fetchNetease = async (): Promise<boolean> => {
|
||||
try {
|
||||
const lrc = await fetchNeteaselyrics(currentTrack.artist ?? '', currentTrack.title);
|
||||
if (!lrc) return false;
|
||||
const lines = parseLrc(lrc).filter(l => !NETEASE_META.test(l.text));
|
||||
const synced = lines.length > 0 ? lines : null;
|
||||
if (!synced) return false;
|
||||
store({ syncedLines: synced, plainLyrics: null, source: 'netease', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
// Embedded lyrics from local file always win (most accurate SYLT data).
|
||||
if (cancelled) return;
|
||||
if (await fetchEmbedded()) return;
|
||||
|
||||
const [first, second] = lyricsServerFirst
|
||||
? [fetchServer, fetchLrclibFn]
|
||||
: [fetchLrclibFn, fetchServer];
|
||||
@@ -115,11 +172,16 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
if (await first()) return;
|
||||
if (cancelled) return;
|
||||
if (await second()) return;
|
||||
// Netease as last fallback — only when explicitly enabled
|
||||
if (enableNeteaselyrics) {
|
||||
if (cancelled) return;
|
||||
if (await fetchNetease()) return;
|
||||
}
|
||||
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack?.id, enableNeteaselyrics]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { syncedLines, plainLyrics, source, loading, notFound };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { InternetRadioStation } from '../api/subsonic';
|
||||
import {
|
||||
guessAzuraCastApiUrl,
|
||||
normaliseAzuraCastHomepageUrl,
|
||||
fetchAzuraCastNowPlaying,
|
||||
type AzuraCastNowPlaying,
|
||||
type AzuraCastSong,
|
||||
} from '../api/azuracast';
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
|
||||
|
||||
export interface RadioHistoryItem {
|
||||
song: AzuraCastSong;
|
||||
playedAt?: number; // unix timestamp
|
||||
}
|
||||
|
||||
export interface RadioMetadata {
|
||||
/** Metadata source that is currently active. */
|
||||
source: RadioMetadataSource;
|
||||
/** Station name (from ICY icy-name or AzuraCast station.name). */
|
||||
stationName?: string;
|
||||
/** Current track title (combined or individual fields). */
|
||||
currentTitle?: string;
|
||||
currentArtist?: string;
|
||||
currentAlbum?: string;
|
||||
currentArt?: string;
|
||||
/** AzuraCast-only: seconds elapsed in current track. */
|
||||
elapsed?: number;
|
||||
/** AzuraCast-only: total duration of current track in seconds. */
|
||||
duration?: number;
|
||||
/** AzuraCast-only: number of current listeners. */
|
||||
listeners?: number;
|
||||
/** AzuraCast-only: last N played tracks. */
|
||||
history: RadioHistoryItem[];
|
||||
/** AzuraCast-only: next track queued. */
|
||||
nextSong?: AzuraCastSong;
|
||||
}
|
||||
|
||||
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
|
||||
|
||||
interface IcyMetadataResult {
|
||||
stream_title?: string;
|
||||
icy_name?: string;
|
||||
icy_genre?: string;
|
||||
icy_url?: string;
|
||||
icy_description?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
|
||||
const sep = streamTitle.indexOf(' - ');
|
||||
if (sep !== -1) {
|
||||
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
|
||||
}
|
||||
return { title: streamTitle };
|
||||
}
|
||||
|
||||
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
|
||||
const nowPlaying = np.now_playing;
|
||||
const song = nowPlaying?.song;
|
||||
return {
|
||||
source: 'azuracast',
|
||||
stationName: np.station?.name,
|
||||
currentTitle: song?.title,
|
||||
currentArtist: song?.artist,
|
||||
currentAlbum: song?.album,
|
||||
currentArt: song?.art,
|
||||
elapsed: nowPlaying?.elapsed,
|
||||
duration: nowPlaying?.duration,
|
||||
listeners: np.listeners?.current,
|
||||
history: (np.song_history ?? []).slice(0, 5).map(h => ({
|
||||
song: h.song,
|
||||
playedAt: h.played_at,
|
||||
})),
|
||||
nextSong: np.playing_next?.song ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AZURACAST_POLL_MS = 15_000;
|
||||
const ICY_POLL_MS = 30_000;
|
||||
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
|
||||
|
||||
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
|
||||
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
|
||||
|
||||
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
|
||||
const elapsedRef = useRef<number | null>(null);
|
||||
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const stationRef = useRef<InternetRadioStation | null>(null);
|
||||
|
||||
// Store resolved AzuraCast API URL for the current station (or null).
|
||||
const azuraCastUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Stop the elapsed ticker.
|
||||
function stopElapsedTick() {
|
||||
if (elapsedIntervalRef.current) {
|
||||
clearInterval(elapsedIntervalRef.current);
|
||||
elapsedIntervalRef.current = null;
|
||||
}
|
||||
elapsedRef.current = null;
|
||||
}
|
||||
|
||||
// Start a 1-second elapsed ticker that advances the stored elapsed value and
|
||||
// updates the metadata state so the progress bar moves smoothly between polls.
|
||||
function startElapsedTick(initial: number) {
|
||||
stopElapsedTick();
|
||||
elapsedRef.current = initial;
|
||||
elapsedIntervalRef.current = setInterval(() => {
|
||||
if (elapsedRef.current === null) return;
|
||||
elapsedRef.current += 1;
|
||||
setMetadata(prev =>
|
||||
prev.source === 'azuracast'
|
||||
? { ...prev, elapsed: elapsedRef.current! }
|
||||
: prev
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!station) {
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
return;
|
||||
}
|
||||
|
||||
stationRef.current = station;
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Determine which AzuraCast API URL to try, in priority order:
|
||||
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
|
||||
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
|
||||
const candidateApiUrl =
|
||||
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
|
||||
guessAzuraCastApiUrl(station.streamUrl);
|
||||
|
||||
async function pollAzuraCast(apiUrl: string) {
|
||||
if (cancelled) return;
|
||||
const np = await fetchAzuraCastNowPlaying(apiUrl);
|
||||
if (cancelled) return;
|
||||
if (np) {
|
||||
const m = nowPlayingToMetadata(np);
|
||||
setMetadata(m);
|
||||
startElapsedTick(m.elapsed ?? 0);
|
||||
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
|
||||
} else {
|
||||
// AzuraCast check failed — fall back to ICY
|
||||
azuraCastUrlRef.current = null;
|
||||
pollIcy();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollIcy() {
|
||||
if (cancelled) return;
|
||||
const currentStation = stationRef.current;
|
||||
if (!currentStation) return;
|
||||
try {
|
||||
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
|
||||
if (cancelled) return;
|
||||
if (result.stream_title || result.icy_name) {
|
||||
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
|
||||
setMetadata({
|
||||
source: 'icy',
|
||||
stationName: result.icy_name,
|
||||
currentTitle: parsed?.title,
|
||||
currentArtist: parsed?.artist,
|
||||
history: [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ICY metadata not available — leave empty metadata
|
||||
}
|
||||
if (!cancelled) {
|
||||
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off detection and polling.
|
||||
if (candidateApiUrl) {
|
||||
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
|
||||
azuraCastUrlRef.current = candidateApiUrl;
|
||||
pollAzuraCast(candidateApiUrl);
|
||||
} else {
|
||||
pollIcy();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
stopElapsedTick();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [station?.id, station?.streamUrl, station?.homepageUrl]);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@@ -94,6 +94,8 @@ export const deTranslation = {
|
||||
addToQueue: 'Zur Warteschlange hinzufügen',
|
||||
enqueueAlbum: 'Ganzes Album einreihen',
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
||||
lfmLove: 'Auf Last.fm liken',
|
||||
lfmUnlove: 'Last.fm-Like entfernen',
|
||||
favorite: 'Favorisieren',
|
||||
@@ -350,6 +352,20 @@ export const deTranslation = {
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} verfügbar',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'Neue Version verfügbar',
|
||||
updaterChangelog: 'Was ist neu',
|
||||
updaterDownloadBtn: 'Jetzt herunterladen',
|
||||
updaterSkipBtn: 'Version überspringen',
|
||||
updaterRemindBtn: 'Später erinnern',
|
||||
updaterDone: 'Download abgeschlossen',
|
||||
updaterShowFolder: 'Im Ordner anzeigen',
|
||||
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
|
||||
updaterAurHint: 'Update über AUR installieren:',
|
||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||
updaterRetryBtn: 'Erneut versuchen',
|
||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||
durationMinutesOnly: '{{minutes}} Min.',
|
||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -382,6 +398,11 @@ export const deTranslation = {
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
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:
|
||||
'Instant Mix ist kürzlich fehlgeschlagen — Navidrome-Plugin und AudioMuse-API prüfen. Ohne Server-Treffer werden ähnliche Künstler über Last.fm geladen.',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -465,6 +486,8 @@ export const deTranslation = {
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
lyricsServerFirst: 'Server-Lyrics bevorzugen',
|
||||
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music Lyrics',
|
||||
enableNeteaselyricsDesc: 'Netease Cloud Music als letzte Lyrics-Quelle verwenden, wenn Server und LRCLIB nichts liefern. Besonders gut für asiatische und internationale Musik.',
|
||||
downloadsTitle: 'ZIP-Export & Archivierung',
|
||||
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
@@ -557,6 +580,9 @@ export const deTranslation = {
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
preloadMode: 'Nächsten Track vorpuffern',
|
||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
||||
nextTrackBufferingTitle: 'Nächster Track – Pufferung',
|
||||
preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck – nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.',
|
||||
preloadOff: 'Aus',
|
||||
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
|
||||
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
|
||||
preloadCustom: 'Benutzerdefiniert',
|
||||
@@ -793,6 +819,7 @@ export const deTranslation = {
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
lyricsSourceServer: 'Quelle: Server',
|
||||
lyricsSourceLrclib: 'Quelle: LRCLIB',
|
||||
lyricsSourceNetease: 'Quelle: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
@@ -906,6 +933,10 @@ export const deTranslation = {
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
listenerCount_one: '{{count}} Hörer',
|
||||
listenerCount_other: '{{count}} Hörer',
|
||||
recentlyPlayed: 'Zuletzt gespielt',
|
||||
upNext: 'Als Nächstes',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Leerer Ordner',
|
||||
|
||||
@@ -95,6 +95,8 @@ export const enTranslation = {
|
||||
addToQueue: 'Add to Queue',
|
||||
enqueueAlbum: 'Enqueue Album',
|
||||
startRadio: 'Start Radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
||||
lfmLove: 'Love on Last.fm',
|
||||
lfmUnlove: 'Unlove on Last.fm',
|
||||
favorite: 'Favorite',
|
||||
@@ -351,6 +353,20 @@ export const enTranslation = {
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is available',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'New Version Available',
|
||||
updaterChangelog: "What's New",
|
||||
updaterDownloadBtn: 'Download Now',
|
||||
updaterSkipBtn: 'Skip this Version',
|
||||
updaterRemindBtn: 'Remind me Later',
|
||||
updaterDone: 'Download complete',
|
||||
updaterShowFolder: 'Show in Folder',
|
||||
updaterInstallHint: 'Close Psysonic and run the installer manually.',
|
||||
updaterAurHint: 'Install the update via AUR:',
|
||||
updaterErrorMsg: 'Download failed',
|
||||
updaterRetryBtn: 'Retry',
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -383,6 +399,11 @@ export const enTranslation = {
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
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.',
|
||||
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',
|
||||
failed: 'Failed',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -466,6 +487,8 @@ export const enTranslation = {
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
lyricsServerFirst: 'Prefer server lyrics',
|
||||
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music lyrics',
|
||||
enableNeteaselyricsDesc: 'Use Netease Cloud Music as a last-resort lyrics source when server and LRCLIB both return nothing. Best coverage for Asian and international music.',
|
||||
downloadsTitle: 'ZIP Export & Archiving',
|
||||
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
@@ -558,6 +581,9 @@ export const enTranslation = {
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
preloadMode: 'Preload Next Track',
|
||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
||||
nextTrackBufferingTitle: 'Next Track Buffering',
|
||||
preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.',
|
||||
preloadOff: 'Off',
|
||||
preloadBalanced: 'Balanced (30 s before end)',
|
||||
preloadEarly: 'Early (after 5 s of playback)',
|
||||
preloadCustom: 'Custom',
|
||||
@@ -794,6 +820,7 @@ export const enTranslation = {
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
lyricsSourceServer: 'Source: Server',
|
||||
lyricsSourceLrclib: 'Source: LRCLIB',
|
||||
lyricsSourceNetease: 'Source: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
@@ -907,6 +934,10 @@ export const enTranslation = {
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
listenerCount_one: '{{count}} listener',
|
||||
listenerCount_other: '{{count}} listeners',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
upNext: 'Up Next',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Empty folder',
|
||||
|
||||
@@ -94,6 +94,8 @@ export const frTranslation = {
|
||||
addToQueue: 'Ajouter à la file',
|
||||
enqueueAlbum: 'Mettre l\'album en file',
|
||||
startRadio: 'Démarrer la radio',
|
||||
instantMix: 'Mix instantané',
|
||||
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
||||
lfmLove: 'Aimer sur Last.fm',
|
||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||
favorite: 'Favori',
|
||||
@@ -350,6 +352,20 @@ export const frTranslation = {
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est disponible',
|
||||
updaterWebsite: 'Site Web',
|
||||
updaterModalTitle: 'Nouvelle version disponible',
|
||||
updaterChangelog: 'Nouveautés',
|
||||
updaterDownloadBtn: 'Télécharger maintenant',
|
||||
updaterSkipBtn: 'Ignorer cette version',
|
||||
updaterRemindBtn: 'Me rappeler plus tard',
|
||||
updaterDone: 'Téléchargement terminé',
|
||||
updaterShowFolder: 'Afficher dans le dossier',
|
||||
updaterInstallHint: 'Fermez Psysonic et lancez le programme d\'installation manuellement.',
|
||||
updaterAurHint: 'Installer la mise à jour via AUR :',
|
||||
updaterErrorMsg: 'Échec du téléchargement',
|
||||
updaterRetryBtn: 'Réessayer',
|
||||
durationHoursMinutes: '{{hours}} h {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -382,6 +398,11 @@ export const frTranslation = {
|
||||
testBtn: 'Tester la connexion',
|
||||
testingBtn: 'Test en cours…',
|
||||
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
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:
|
||||
'Le mix instantané a échoué récemment — vérifiez le plugin Navidrome et l’API AudioMuse. Les artistes similaires utilisent Last.fm si le serveur ne renvoie rien.',
|
||||
connected: 'Connecté',
|
||||
failed: 'Échec',
|
||||
eqTitle: 'Égaliseur',
|
||||
@@ -463,6 +484,8 @@ export const frTranslation = {
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
lyricsServerFirst: 'Préférer les paroles du serveur',
|
||||
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
|
||||
enableNeteaselyrics: 'Paroles Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Utiliser Netease Cloud Music en dernier recours lorsque le serveur et LRCLIB ne retournent rien. Meilleure couverture pour la musique asiatique et internationale.',
|
||||
downloadsTitle: 'Export ZIP & Archivage',
|
||||
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
@@ -555,6 +578,9 @@ export const frTranslation = {
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
preloadMode: 'Précharger la piste suivante',
|
||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
|
||||
nextTrackBufferingTitle: 'Piste suivante – mise en mémoire tampon',
|
||||
preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.",
|
||||
preloadOff: 'Désactivé',
|
||||
preloadBalanced: 'Équilibré (30 s avant la fin)',
|
||||
preloadEarly: 'Tôt (après 5 s de lecture)',
|
||||
preloadCustom: 'Personnalisé',
|
||||
@@ -791,6 +817,7 @@ export const frTranslation = {
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
lyricsSourceServer: 'Source : Serveur',
|
||||
lyricsSourceLrclib: 'Source : LRCLIB',
|
||||
lyricsSourceNetease: 'Source : Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
@@ -901,6 +928,10 @@ export const frTranslation = {
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
listenerCount_one: '{{count}} auditeur',
|
||||
listenerCount_other: '{{count}} auditeurs',
|
||||
recentlyPlayed: 'Récemment joués',
|
||||
upNext: 'À suivre',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Dossier vide',
|
||||
|
||||
@@ -94,6 +94,8 @@ export const nbTranslation = {
|
||||
addToQueue: 'Legg til i kø',
|
||||
enqueueAlbum: 'Legg albumet i kø',
|
||||
startRadio: 'Start radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
||||
lfmLove: 'Lik på Last.fm',
|
||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||
favorite: 'Favoritt',
|
||||
@@ -350,6 +352,20 @@ export const nbTranslation = {
|
||||
updaterAvailable: 'Ny versjon er tilgjengelig',
|
||||
updaterVersion: 'v{{version}} er tilgjengelig',
|
||||
updaterWebsite: 'Nettsted',
|
||||
updaterModalTitle: 'Ny versjon tilgjengelig',
|
||||
updaterChangelog: 'Hva er nytt',
|
||||
updaterDownloadBtn: 'Last ned nå',
|
||||
updaterSkipBtn: 'Hopp over denne versjonen',
|
||||
updaterRemindBtn: 'Påminn meg senere',
|
||||
updaterDone: 'Nedlasting fullført',
|
||||
updaterShowFolder: 'Vis i mappe',
|
||||
updaterInstallHint: 'Lukk Psysonic og kjør installasjonsprogrammet manuelt.',
|
||||
updaterAurHint: 'Installer oppdateringen via AUR:',
|
||||
updaterErrorMsg: 'Nedlasting mislyktes',
|
||||
updaterRetryBtn: 'Prøv igjen',
|
||||
durationHoursMinutes: '{{hours}} t {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Åpne på GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Innstillinger',
|
||||
@@ -382,6 +398,11 @@ export const nbTranslation = {
|
||||
testBtn: 'Test tilkobling',
|
||||
testingBtn: 'Tester…',
|
||||
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
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:
|
||||
'Instant Mix feilet nylig — sjekk Navidrome-plugin og AudioMuse API. Lignende artister hentes fra Last.fm hvis serveren ikke returnerer noe.',
|
||||
connected: 'Tilkoblet',
|
||||
failed: 'Mislyktes',
|
||||
eqTitle: 'Jevnstiller',
|
||||
@@ -462,6 +483,8 @@ export const nbTranslation = {
|
||||
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
|
||||
lyricsServerFirst: 'Foretrekk server-sangtekst',
|
||||
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
|
||||
enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.',
|
||||
downloadsTitle: 'ZIP Eksport & Arkivering',
|
||||
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
|
||||
downloadsDefault: 'Standard nedlastingsmappe',
|
||||
@@ -557,6 +580,9 @@ export const nbTranslation = {
|
||||
experimental: 'Eksperimentell',
|
||||
preloadMode: 'Forhåndslast neste spor',
|
||||
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
|
||||
nextTrackBufferingTitle: 'Neste spor – bufring',
|
||||
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål – bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
|
||||
preloadOff: 'Av',
|
||||
preloadBalanced: 'Balansert (30 s før slutt)',
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
@@ -790,6 +816,7 @@ export const nbTranslation = {
|
||||
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
|
||||
lyricsSourceServer: 'Kilde: Server',
|
||||
lyricsSourceLrclib: 'Kilde: LRCLIB',
|
||||
lyricsSourceNetease: 'Kilde: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Sanginfo',
|
||||
@@ -900,6 +927,10 @@ export const nbTranslation = {
|
||||
favorite: 'Legg til i favoritter',
|
||||
unfavorite: 'Fjern fra favoritter',
|
||||
noFavorites: 'Ingen favorittstasjoner.',
|
||||
listenerCount_one: '{{count}} lytter',
|
||||
listenerCount_other: '{{count}} lyttere',
|
||||
recentlyPlayed: 'Nylig spilt',
|
||||
upNext: 'Neste ut',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Tom mappe',
|
||||
|
||||
@@ -94,6 +94,8 @@ export const nlTranslation = {
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
enqueueAlbum: 'Album in wachtrij',
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
||||
lfmLove: 'Liken op Last.fm',
|
||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||
favorite: 'Favoriet',
|
||||
@@ -350,6 +352,20 @@ export const nlTranslation = {
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} beschikbaar',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'Nieuwe versie beschikbaar',
|
||||
updaterChangelog: 'Wat is er nieuw',
|
||||
updaterDownloadBtn: 'Nu downloaden',
|
||||
updaterSkipBtn: 'Deze versie overslaan',
|
||||
updaterRemindBtn: 'Later herinneren',
|
||||
updaterDone: 'Download voltooid',
|
||||
updaterShowFolder: 'Tonen in map',
|
||||
updaterInstallHint: 'Sluit Psysonic en voer het installatieprogramma handmatig uit.',
|
||||
updaterAurHint: 'Update installeren via AUR:',
|
||||
updaterErrorMsg: 'Downloaden mislukt',
|
||||
updaterRetryBtn: 'Opnieuw proberen',
|
||||
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Openen op GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -382,6 +398,11 @@ export const nlTranslation = {
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
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:
|
||||
'Instant Mix is onlangs mislukt — controleer de Navidrome-plugin en AudioMuse API. Zonder serverresultaten worden vergelijkbare artiesten via Last.fm geladen.',
|
||||
connected: 'Verbonden',
|
||||
failed: 'Mislukt',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -463,6 +484,8 @@ export const nlTranslation = {
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
lyricsServerFirst: 'Server-songtekst voorrang geven',
|
||||
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music songteksten',
|
||||
enableNeteaselyricsDesc: 'Gebruik Netease Cloud Music als laatste bron wanneer server en LRCLIB niets opleveren. Beste dekking voor Aziatische en internationale muziek.',
|
||||
downloadsTitle: 'ZIP-export & Archivering',
|
||||
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
@@ -555,6 +578,9 @@ export const nlTranslation = {
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
preloadMode: 'Volgend nummer vooraf laden',
|
||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
||||
nextTrackBufferingTitle: 'Volgend nummer – buffering',
|
||||
preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.',
|
||||
preloadOff: 'Uit',
|
||||
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
|
||||
preloadEarly: 'Vroeg (na 5 s afspelen)',
|
||||
preloadCustom: 'Aangepast',
|
||||
@@ -791,6 +817,7 @@ export const nlTranslation = {
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
lyricsSourceServer: 'Bron: Server',
|
||||
lyricsSourceLrclib: 'Bron: LRCLIB',
|
||||
lyricsSourceNetease: 'Bron: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
@@ -901,6 +928,10 @@ export const nlTranslation = {
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
listenerCount_one: '{{count}} luisteraar',
|
||||
listenerCount_other: '{{count}} luisteraars',
|
||||
recentlyPlayed: 'Recent gespeeld',
|
||||
upNext: 'Volgende',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Lege map',
|
||||
|
||||
+38
-5
@@ -21,7 +21,7 @@ export const ruTranslation = {
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
mostPlayed: 'Популярное',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
libraryScope: 'Область медиатеки',
|
||||
@@ -31,7 +31,7 @@ export const ruTranslation = {
|
||||
hero: 'Подборка',
|
||||
starred: 'Личное избранное',
|
||||
recent: 'Недавно добавлено',
|
||||
mostPlayed: 'Чаще всего',
|
||||
mostPlayed: 'Популярное',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
discover: 'Обзор',
|
||||
loadMore: 'Ещё',
|
||||
@@ -95,6 +95,8 @@ export const ruTranslation = {
|
||||
addToQueue: 'В конец очереди',
|
||||
enqueueAlbum: 'Альбом в очередь',
|
||||
startRadio: 'Радио по похожим',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
@@ -364,6 +366,20 @@ export const ruTranslation = {
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'Версия {{version}} доступна',
|
||||
updaterWebsite: 'Сайт',
|
||||
updaterModalTitle: 'Доступна новая версия',
|
||||
updaterChangelog: 'Что нового',
|
||||
updaterDownloadBtn: 'Скачать сейчас',
|
||||
updaterSkipBtn: 'Пропустить эту версию',
|
||||
updaterRemindBtn: 'Напомнить позже',
|
||||
updaterDone: 'Загрузка завершена',
|
||||
updaterShowFolder: 'Показать в папке',
|
||||
updaterInstallHint: 'Закройте Psysonic и запустите установщик вручную.',
|
||||
updaterAurHint: 'Установить обновление через AUR:',
|
||||
updaterErrorMsg: 'Ошибка загрузки',
|
||||
updaterRetryBtn: 'Повторить',
|
||||
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
||||
durationMinutesOnly: '{{minutes}}мин',
|
||||
updaterOpenGitHub: 'Открыть на GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
@@ -397,6 +413,11 @@ export const ruTranslation = {
|
||||
testBtn: 'Проверить',
|
||||
testingBtn: 'Проверка…',
|
||||
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
|
||||
audiomuseIssueHint:
|
||||
'Недавно не удалось собрать Instant Mix — проверьте плагин Navidrome и API AudioMuse. Похожие исполнители подтянутся с Last.fm, если сервер ничего не вернёт.',
|
||||
connected: 'Подключено',
|
||||
failed: 'Ошибка',
|
||||
eqTitle: 'Эквалайзер',
|
||||
@@ -484,6 +505,10 @@ export const ruTranslation = {
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
lyricsServerFirst: 'Предпочитать серверные тексты',
|
||||
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы использовать LRCLIB сначала.',
|
||||
enableNeteaselyrics: 'Тексты Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда сервер и LRCLIB не находят ничего. Лучшее покрытие для азиатской и международной музыки.',
|
||||
downloadsTitle: 'Экспорт ZIP и архивы',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
|
||||
downloadsDefault: 'Папка «Загрузки» по умолчанию',
|
||||
@@ -581,6 +606,9 @@ export const ruTranslation = {
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||
nextTrackBufferingTitle: 'Буферизация следующего трека',
|
||||
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
|
||||
preloadOff: 'Выкл.',
|
||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||
preloadEarly: 'Рано (через 5 с после старта)',
|
||||
preloadCustom: 'Свой интервал',
|
||||
@@ -780,8 +808,8 @@ export const ruTranslation = {
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Треки',
|
||||
statGenres: 'Жанры',
|
||||
statPlaytime: 'Всего прослушано',
|
||||
genreInsights: 'Жанры подробнее',
|
||||
statPlaytime: 'Время звучания',
|
||||
genreInsights: 'По жанрам',
|
||||
formatDistribution: 'Форматы',
|
||||
formatSample: 'Выборка {{n}} треков',
|
||||
computing: 'Считаем…',
|
||||
@@ -848,6 +876,7 @@ export const ruTranslation = {
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsSourceNetease: 'Источник: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
@@ -919,7 +948,7 @@ export const ruTranslation = {
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Часто слушаемое',
|
||||
title: 'Популярное',
|
||||
topArtists: 'Топ исполнителей',
|
||||
topAlbums: 'Топ альбомов',
|
||||
plays: '{{n}} прослушиваний',
|
||||
@@ -958,6 +987,10 @@ export const ruTranslation = {
|
||||
favorite: 'В избранное',
|
||||
unfavorite: 'Убрать из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
listenerCount_one: '{{count}} слушатель',
|
||||
listenerCount_other: '{{count}} слушателей',
|
||||
recentlyPlayed: 'Недавно сыгранное',
|
||||
upNext: 'Следующий',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Папка пуста',
|
||||
|
||||
+32
-1
@@ -94,6 +94,8 @@ export const zhTranslation = {
|
||||
addToQueue: '添加到队列',
|
||||
enqueueAlbum: '专辑加入队列',
|
||||
startRadio: '开始电台',
|
||||
instantMix: '即时混音',
|
||||
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||
lfmLove: '在 Last.fm 上标记喜欢',
|
||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||
favorite: '收藏',
|
||||
@@ -346,6 +348,20 @@ export const zhTranslation = {
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已发布',
|
||||
updaterWebsite: '官网',
|
||||
updaterModalTitle: '有新版本可用',
|
||||
updaterChangelog: '更新内容',
|
||||
updaterDownloadBtn: '立即下载',
|
||||
updaterSkipBtn: '跳过此版本',
|
||||
updaterRemindBtn: '稍后提醒',
|
||||
updaterDone: '下载完成',
|
||||
updaterShowFolder: '在文件夹中显示',
|
||||
updaterInstallHint: '请关闭 Psysonic 并手动运行安装程序。',
|
||||
updaterAurHint: '通过 AUR 安装更新:',
|
||||
updaterErrorMsg: '下载失败',
|
||||
updaterRetryBtn: '重试',
|
||||
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
||||
durationMinutesOnly: '{{minutes}}分钟',
|
||||
updaterOpenGitHub: '在 GitHub 上打开',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -378,6 +394,11 @@ export const zhTranslation = {
|
||||
testBtn: '测试连接',
|
||||
testingBtn: '正在测试…',
|
||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI(Navidrome)',
|
||||
audiomuseDesc:
|
||||
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
|
||||
audiomuseIssueHint:
|
||||
'近期即时混音失败 — 请检查 Navidrome 插件与 AudioMuse API。若服务器无结果,将回退使用 Last.fm 的相似艺人。',
|
||||
connected: '已连接',
|
||||
failed: '失败',
|
||||
eqTitle: '均衡器',
|
||||
@@ -459,6 +480,8 @@ export const zhTranslation = {
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
|
||||
enableNeteaselyrics: '网易云音乐歌词',
|
||||
enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
@@ -551,6 +574,9 @@ export const zhTranslation = {
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
nextTrackBufferingTitle: '下一曲目缓冲',
|
||||
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
|
||||
preloadOff: '关闭',
|
||||
preloadBalanced: '均衡(结束前30秒)',
|
||||
preloadEarly: '提前(播放5秒后)',
|
||||
preloadCustom: '自定义',
|
||||
@@ -727,7 +753,7 @@ export const zhTranslation = {
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
statPlaytime: '总播放时长',
|
||||
statPlaytime: '音频总时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
@@ -787,6 +813,7 @@ export const zhTranslation = {
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
lyricsSourceServer: '来源:服务器',
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
lyricsSourceNetease: '来源:网易云',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
@@ -897,6 +924,10 @@ export const zhTranslation = {
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
listenerCount_one: '{{count}} 位听众',
|
||||
listenerCount_other: '{{count}} 位听众',
|
||||
recentlyPlayed: '最近播放',
|
||||
upNext: '即将播放',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: '空文件夹',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Play, SlidersHorizontal } from 'lucide-react';
|
||||
import { Play, SlidersVertical } from 'lucide-react';
|
||||
import {
|
||||
search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs,
|
||||
SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong,
|
||||
@@ -140,7 +140,7 @@ export default function AdvancedSearch() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<SlidersHorizontal size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<SlidersVertical size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
{t('search.advanced')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function Albums() {
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{t('albums.yearFilterLabel')}
|
||||
</span>
|
||||
<input
|
||||
@@ -229,9 +229,9 @@ export default function Albums() {
|
||||
placeholder={t('albums.yearFrom')}
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>–</span>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-muted)' }}>–</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -240,16 +240,15 @@ export default function Albums() {
|
||||
placeholder={t('albums.yearTo')}
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
style={{ padding: '4px 6px' }}
|
||||
>
|
||||
<X size={13} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+84
-13
@@ -57,6 +57,7 @@ export default function ArtistDetail() {
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
@@ -73,6 +74,9 @@ export default function ArtistDetail() {
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
@@ -95,12 +99,6 @@ export default function ArtistDetail() {
|
||||
// Render the page immediately from local data
|
||||
setLoading(false);
|
||||
|
||||
// Fetch artist info (may trigger slow external lookup on the server)
|
||||
// and top songs in the background — do not block rendering
|
||||
getArtistInfo(id).then(artistInfo => {
|
||||
if (!cancelled) setInfo(artistInfo ?? null);
|
||||
}).catch(() => {});
|
||||
|
||||
getTopSongs(artistData.artist.name).then(songsData => {
|
||||
if (!cancelled) setTopSongs(songsData ?? []);
|
||||
}).catch(() => {});
|
||||
@@ -110,6 +108,23 @@ export default function ArtistDetail() {
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setArtistInfoLoading(true);
|
||||
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(artistInfo => {
|
||||
if (!cancelled) setInfo(artistInfo ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInfo(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setArtistInfoLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id, audiomuseNavidromeEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||
@@ -174,7 +189,7 @@ export default function ArtistDetail() {
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||
@@ -197,7 +212,52 @@ export default function ArtistDetail() {
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]);
|
||||
|
||||
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
|
||||
useEffect(() => {
|
||||
if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
||||
if (artistInfoLoading) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
||||
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [
|
||||
artist?.id,
|
||||
artist?.name,
|
||||
musicLibraryFilterVersion,
|
||||
audiomuseNavidromeEnabled,
|
||||
artistInfoLoading,
|
||||
info?.similarArtist?.length,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audiomuseNavidromeEnabled) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) {
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(false);
|
||||
}
|
||||
}, [id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
@@ -331,6 +391,18 @@ export default function ArtistDetail() {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
albumCount: sa.albumCount,
|
||||
}));
|
||||
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
|
||||
const showLastfmSimilar =
|
||||
lastfmIsConfigured() &&
|
||||
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
|
||||
(similarLoading || similarArtists.length > 0);
|
||||
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
@@ -564,20 +636,19 @@ export default function ArtistDetail() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Similar Artists (Last.fm) */}
|
||||
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
|
||||
{showSimilarSection && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{similarLoading ? (
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{similarArtists.map(a => (
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="artist-ext-link"
|
||||
@@ -592,7 +663,7 @@ export default function ArtistDetail() {
|
||||
)}
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ export default function Favorites() {
|
||||
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const playRadio = usePlayerStore(s => s.playRadio);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -19,7 +19,10 @@ import { showToast } from '../utils/toast';
|
||||
|
||||
export default function InternetRadio() {
|
||||
const { t } = useTranslation();
|
||||
const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore();
|
||||
const playRadio = usePlayerStore(s => s.playRadio);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
const [stations, setStations] = useState<InternetRadioStation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
+18
-5
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
@@ -36,16 +36,16 @@ export default function Login() {
|
||||
|
||||
// Test connection directly with entered credentials — don't touch the store yet.
|
||||
// This avoids any race condition with Zustand's async store rehydration.
|
||||
let ok = false;
|
||||
let ping: Awaited<ReturnType<typeof pingWithCredentials>> = { ok: false };
|
||||
try {
|
||||
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
||||
ping = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
||||
} catch {
|
||||
ok = false;
|
||||
ping = { ok: false };
|
||||
}
|
||||
|
||||
setConnecting(false);
|
||||
|
||||
if (ok) {
|
||||
if (ping.ok) {
|
||||
// Connection succeeded — now persist to store
|
||||
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
|
||||
let serverId: string;
|
||||
@@ -63,6 +63,19 @@ export default function Login() {
|
||||
password: profile.password,
|
||||
});
|
||||
}
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
|
||||
scheduleInstantMixProbeForServer(
|
||||
serverId,
|
||||
profile.url.trim(),
|
||||
profile.username.trim(),
|
||||
profile.password,
|
||||
identity,
|
||||
);
|
||||
setActiveServer(serverId);
|
||||
setLoggedIn(true);
|
||||
setStatus('ok');
|
||||
|
||||
+137
-5
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import {
|
||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
SubsonicSong, SubsonicArtistInfo,
|
||||
} from '../api/subsonic';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -211,15 +213,22 @@ export default function NowPlaying() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
|
||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||
|
||||
// Radio metadata (ICY or AzuraCast)
|
||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||
|
||||
// Extra song metadata
|
||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -231,8 +240,10 @@ export default function NowPlaying() {
|
||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
|
||||
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
|
||||
}, [currentTrack?.artistId]);
|
||||
getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(setArtistInfo)
|
||||
.catch(() => setArtistInfo(null));
|
||||
}, [currentTrack?.artistId, audiomuseNavidromeEnabled]);
|
||||
|
||||
// Album tracks
|
||||
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
|
||||
@@ -259,15 +270,136 @@ export default function NowPlaying() {
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
|
||||
// Radio cover
|
||||
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : '';
|
||||
const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey);
|
||||
|
||||
const similarArtists = artistInfo?.similarArtist ?? [];
|
||||
|
||||
// ── Radio now-playing section ────────────────────────────────────────────────
|
||||
const radioNowPlaying = currentRadio && !currentTrack && (
|
||||
<div className="np-radio-section">
|
||||
|
||||
{/* Station hero */}
|
||||
<div className="np-hero-card">
|
||||
<div className="np-hero-left">
|
||||
<div className="np-hero-info">
|
||||
<div className="np-title" style={{ color: 'var(--accent)' }}>
|
||||
{currentRadio.name}
|
||||
</div>
|
||||
{radioMeta.currentTitle && (
|
||||
<div className="np-artist-album">
|
||||
{radioMeta.currentArtist && (
|
||||
<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>
|
||||
)}
|
||||
<span>{radioMeta.currentTitle}</span>
|
||||
{radioMeta.currentAlbum && (
|
||||
<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="np-tech-row">
|
||||
<span className="np-badge np-badge-live">
|
||||
<Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}
|
||||
</span>
|
||||
{radioMeta.source === 'azuracast' && (
|
||||
<span className="np-badge np-badge-azuracast">AzuraCast</span>
|
||||
)}
|
||||
{radioMeta.listeners != null && (
|
||||
<span className="np-badge">
|
||||
<Users size={10} style={{ marginRight: 3 }} />
|
||||
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AzuraCast progress bar */}
|
||||
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
|
||||
<div className="np-radio-progress-wrap">
|
||||
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
|
||||
<div className="np-radio-progress-bar">
|
||||
<div
|
||||
className="np-radio-progress-fill"
|
||||
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
<div className="np-hero-cover-wrap">
|
||||
{resolvedRadioCover
|
||||
? <img src={resolvedRadioCover} alt={currentRadio.name} className="np-cover" />
|
||||
: radioMeta.currentArt
|
||||
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Placeholder to keep 3-column layout */}
|
||||
<div style={{ flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* Upcoming track */}
|
||||
{radioMeta.nextSong && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">
|
||||
<SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="np-radio-next-track">
|
||||
{radioMeta.nextSong.art && (
|
||||
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<div className="np-radio-track-info">
|
||||
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
|
||||
{radioMeta.nextSong.artist && (
|
||||
<span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Song history */}
|
||||
{radioMeta.history.length > 0 && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">
|
||||
<Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{radioMeta.history.map((item, idx) => (
|
||||
<div key={idx} className="np-album-track">
|
||||
{item.song.art && (
|
||||
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<span className="np-album-track-title truncate">
|
||||
{item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="np-page">
|
||||
|
||||
<div className="np-main">
|
||||
{currentTrack ? (
|
||||
{radioNowPlaying ? (
|
||||
radioNowPlaying
|
||||
) : currentTrack ? (
|
||||
<>
|
||||
{/* ── Hero Card ── */}
|
||||
<div className="np-hero-card">
|
||||
|
||||
@@ -23,6 +23,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
@@ -41,9 +42,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
return formatHumanHoursMinutes(total);
|
||||
}
|
||||
|
||||
function codecLabel(song: SubsonicSong): string {
|
||||
|
||||
@@ -6,18 +6,16 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack } = usePlayerStore();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const removeId = usePlaylistStore((s) => s.removeId);
|
||||
|
||||
|
||||
+312
-214
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
import { showToast } from '../utils/toast';
|
||||
@@ -29,14 +29,17 @@ import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../st
|
||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import Equalizer from '../components/Equalizer';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity';
|
||||
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
|
||||
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{
|
||||
github: 'jiezhuo',
|
||||
@@ -91,8 +94,13 @@ const CONTRIBUTORS = [
|
||||
since: '1.33.0',
|
||||
contributions: [
|
||||
'Russian translation & i18n locale split (PR #106)',
|
||||
'Russian locale refinements using phrasing from ru2 (PR #113)',
|
||||
'Gapless manual skip: honor user-initiated play over pre-chained track (PR #119)',
|
||||
'Per-server music folder filter and sidebar library picker (PR #125)',
|
||||
'Hot playback cache — queue prefetch (PR #123)',
|
||||
'Per-server music folder filter and sidebar library picker (PR #124, PR #125)',
|
||||
'Richer star ratings, skip threshold, and library filtering (PR #130)',
|
||||
'Statistics: scope album and song totals to selected music library (PR #138)',
|
||||
'AudioMuse-AI discovery integration for Navidrome (PR #147)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -101,6 +109,9 @@ const CONTRIBUTORS = [
|
||||
contributions: [
|
||||
'Russian locale improvements (PR #107, PR #120)',
|
||||
'Auto-install script for Debian / RHEL (PR #121)',
|
||||
'Album cover art in Discord Rich Presence via iTunes API (PR #111)',
|
||||
'Tiling WM detection: hide custom TitleBar on Hyprland/Sway/i3/etc. (PR #134)',
|
||||
'Russian translation: lyricsServerFirst settings strings (PR #140)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -109,6 +120,7 @@ const CONTRIBUTORS = [
|
||||
contributions: [
|
||||
'Nightfox.nvim theme group in Open Source Classics (PR #114)',
|
||||
'Switch reqwest to rustls-tls for cross-platform TLS (PR #112)',
|
||||
'ICY stream metadata & AzuraCast Now Playing support (PR #146)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -241,6 +253,10 @@ export default function Settings() {
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'audio') {
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
return;
|
||||
}
|
||||
if (activeTab !== 'storage') return;
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
@@ -306,8 +322,17 @@ export default function Settings() {
|
||||
const testConnection = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
try {
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' }));
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ping.ok) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(server.id, identity);
|
||||
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||
}
|
||||
setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' }));
|
||||
} catch {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
@@ -316,8 +341,15 @@ export default function Settings() {
|
||||
const switchToServer = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
try {
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ok) {
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ping.ok) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(server.id, identity);
|
||||
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
navigate('/');
|
||||
@@ -340,9 +372,16 @@ export default function Settings() {
|
||||
const tempId = '_new';
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||||
try {
|
||||
const ok = await pingWithCredentials(data.url, data.username, data.password);
|
||||
if (ok) {
|
||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||
if (ping.ok) {
|
||||
const id = auth.addServer(data);
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(id, identity);
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
||||
auth.setActiveServer(id);
|
||||
auth.setLoggedIn(true);
|
||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||
@@ -516,8 +555,19 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="divider" />
|
||||
{/* Next Track Buffering */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Download size={18} />
|
||||
<h2>{t('settings.nextTrackBufferingTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
|
||||
{t('settings.preloadHotCacheMutualExclusive')}
|
||||
</div>
|
||||
|
||||
{/* Preload mode */}
|
||||
<div className="settings-toggle-row">
|
||||
@@ -525,31 +575,165 @@ export default function Settings() {
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setPreloadMode(mode)}
|
||||
>
|
||||
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{auth.preloadMode === 'custom' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<label className="toggle-switch" aria-label={t('settings.preloadMode')}>
|
||||
<input
|
||||
type="range"
|
||||
min={5} max={120} step={5}
|
||||
value={auth.preloadCustomSeconds}
|
||||
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
type="checkbox"
|
||||
checked={auth.preloadMode !== 'off'}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
auth.setPreloadMode('balanced');
|
||||
if (auth.hotCacheEnabled) auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setPreloadMode('off');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
||||
</span>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.preloadMode !== 'off' && (
|
||||
<>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setPreloadMode(mode)}
|
||||
>
|
||||
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{auth.preloadMode === 'custom' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5} max={120} step={5}
|
||||
value={auth.preloadCustomSeconds}
|
||||
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* Hot Cache */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.hotCacheEnabled}
|
||||
onChange={async e => {
|
||||
const enabled = e.target.checked;
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
if (auth.preloadMode !== 'off') auth.setPreloadMode('off');
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
}
|
||||
}}
|
||||
id="hot-cache-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.hotCacheDownloadDir && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
auth.setHotCacheDownloadDir('');
|
||||
useHotCacheStore.setState({ entries: {} });
|
||||
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
}}
|
||||
data-tooltip={t('settings.hotCacheDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.hotCacheDownloadDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.hotCacheDirHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-max-mb-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-debounce-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
||||
setHotCacheBytes(b);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -560,17 +744,7 @@ export default function Settings() {
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Waves size={18} />
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{t('settings.hiResTitle')}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
{t('settings.hotCacheAlphaBadge')}
|
||||
</span>
|
||||
</h2>
|
||||
<h2>{t('settings.hiResTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
@@ -699,6 +873,17 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.enableNeteaselyrics')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableNeteaselyricsDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.enableNeteaselyrics')}>
|
||||
<input type="checkbox" checked={auth.enableNeteaselyrics} onChange={e => auth.setEnableNeteaselyrics(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -994,164 +1179,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<HardDrive size={18} />
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{t('settings.hotCacheTitle')}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('settings.hotCacheAlphaBadge')}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.hotCacheDisclaimer')}
|
||||
</div>
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheEnabled')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.hotCacheEnabled}
|
||||
onChange={async e => {
|
||||
const enabled = e.target.checked;
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
}
|
||||
}}
|
||||
id="hot-cache-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.hotCacheDownloadDir && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
auth.setHotCacheDownloadDir('');
|
||||
useHotCacheStore.setState({ entries: {} });
|
||||
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
}}
|
||||
data-tooltip={t('settings.hotCacheDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.hotCacheDownloadDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.hotCacheDirHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={32}
|
||||
max={20000}
|
||||
step={32}
|
||||
value={snapHotCacheMb(auth.hotCacheMaxMb)}
|
||||
onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))}
|
||||
style={{ width: 140 }}
|
||||
id="hot-cache-max-mb-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>
|
||||
{snapHotCacheMb(auth.hotCacheMaxMb)} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))}
|
||||
onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))}
|
||||
style={{ width: 140 }}
|
||||
id="hot-cache-debounce-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
||||
setHotCacheBytes(b);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ZIP Export & Archiving */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
@@ -1303,7 +1330,11 @@ export default function Settings() {
|
||||
<h2>{t('settings.uiScaleTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* TODO: UI scaling is being reworked — disabled until fixed */}
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
|
||||
Interface scaling is currently being reworked and will be available in a future update.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
@@ -1418,7 +1449,7 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||||
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1504,7 +1535,7 @@ export default function Settings() {
|
||||
</p>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||||
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1649,6 +1680,71 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showAudiomuseNavidromeServerSetting(
|
||||
auth.subsonicServerIdentityByServer[srv.id],
|
||||
auth.instantMixProbeByServer[srv.id],
|
||||
) && (
|
||||
<div
|
||||
className="settings-toggle-row"
|
||||
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
|
||||
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{t('settings.audiomuseTitle')}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('settings.hotCacheAlphaBadge')}
|
||||
</span>
|
||||
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
style={{ color: 'var(--color-warning, #f59e0b)', flexShrink: 0 }}
|
||||
data-tooltip={t('settings.audiomuseIssueHint')}
|
||||
aria-label={t('settings.audiomuseIssueHint')}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -1856,14 +1952,16 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+57
-64
@@ -1,5 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import {
|
||||
fetchStatisticsFormatSample,
|
||||
fetchStatisticsLibraryAggregates,
|
||||
fetchStatisticsOverview,
|
||||
getAlbumList,
|
||||
SubsonicAlbum,
|
||||
SubsonicGenre,
|
||||
} from '../api/subsonic';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -15,13 +23,6 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
|
||||
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
function formatPlaytime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
{ key: '7day', label: 'lfmPeriod7day' },
|
||||
{ key: '1month', label: 'lfmPeriod1month' },
|
||||
@@ -59,70 +60,59 @@ export default function Statistics() {
|
||||
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
getGenres().catch(() => []),
|
||||
]).then(([rc, fr, hi, a, g]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setArtistCount(a.length);
|
||||
setTotalSongs(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
||||
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
||||
setGenres(sorted);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
fetchStatisticsOverview()
|
||||
.then(d => {
|
||||
setRecent(d.recent);
|
||||
setFrequent(d.frequent);
|
||||
setHighest(d.highest);
|
||||
setArtistCount(d.artistCount);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
|
||||
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTotalPlaytime(null);
|
||||
setTotalAlbums(null);
|
||||
setTotalSongs(null);
|
||||
setPlaytimeCapped(false);
|
||||
setGenres([]);
|
||||
(async () => {
|
||||
let total = 0;
|
||||
let offset = 0;
|
||||
const pageSize = 500;
|
||||
const maxPages = 10;
|
||||
let capped = false;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
try {
|
||||
const albums = await getAlbumList('newest', pageSize, offset);
|
||||
if (cancelled) return;
|
||||
for (const a of albums) total += (a.duration ?? 0);
|
||||
if (albums.length < pageSize) break;
|
||||
if (page === maxPages - 1) capped = true;
|
||||
offset += pageSize;
|
||||
} catch {
|
||||
break;
|
||||
try {
|
||||
const agg = await fetchStatisticsLibraryAggregates();
|
||||
if (cancelled) return;
|
||||
setTotalPlaytime(agg.playtimeSec);
|
||||
setTotalAlbums(agg.albumsCounted);
|
||||
setTotalSongs(agg.songsCounted);
|
||||
setPlaytimeCapped(agg.capped);
|
||||
setGenres(agg.genres);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTotalPlaytime(0);
|
||||
setTotalAlbums(0);
|
||||
setTotalSongs(0);
|
||||
setPlaytimeCapped(false);
|
||||
setGenres([]);
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setTotalPlaytime(total);
|
||||
setPlaytimeCapped(capped);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// Background fetch: format distribution (sample of 500 random songs)
|
||||
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getRandomSongs(500).then(songs => {
|
||||
if (cancelled) return;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const song of songs) {
|
||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
||||
}
|
||||
const sorted = Object.entries(counts)
|
||||
.map(([format, count]) => ({ format, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
setFormatData(sorted);
|
||||
setFormatSampleSize(songs.length);
|
||||
}).catch(() => {});
|
||||
setFormatData(null);
|
||||
setFormatSampleSize(0);
|
||||
fetchStatisticsFormatSample()
|
||||
.then(s => {
|
||||
if (cancelled) return;
|
||||
setFormatData(s.rows);
|
||||
setFormatSampleSize(s.sampleSize);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
@@ -165,12 +155,15 @@ export default function Statistics() {
|
||||
|
||||
const playtimeDisplay = totalPlaytime === null
|
||||
? t('statistics.computing')
|
||||
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
|
||||
: (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime);
|
||||
|
||||
const countDisplay = (n: number | null) =>
|
||||
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) },
|
||||
{ label: t('statistics.statSongs'), value: countDisplay(totalSongs) },
|
||||
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
|
||||
];
|
||||
|
||||
@@ -206,10 +199,10 @@ export default function Statistics() {
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{topGenres.map(g => (
|
||||
<div key={g.value}>
|
||||
<div key={g.value || '__genre_unknown__'}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
|
||||
{g.value}
|
||||
{g.value.trim() ? g.value : t('statistics.decadeUnknown')}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||
{g.songCount.toLocaleString()}
|
||||
|
||||
+99
-3
@@ -2,6 +2,11 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
export interface ServerProfile {
|
||||
@@ -37,7 +42,7 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
preloadMode: 'balanced' | 'early' | 'custom';
|
||||
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
|
||||
preloadCustomSeconds: number;
|
||||
infiniteQueueEnabled: boolean;
|
||||
showArtistImages: boolean;
|
||||
@@ -48,6 +53,7 @@ interface AuthState {
|
||||
useCustomTitlebar: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
lyricsServerFirst: boolean;
|
||||
enableNeteaselyrics: boolean;
|
||||
showFullscreenLyrics: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
@@ -104,6 +110,27 @@ interface AuthState {
|
||||
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
|
||||
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
|
||||
|
||||
/**
|
||||
* Per server: Navidrome has the AudioMuse-AI plugin — use `getSimilarSongs` (Instant Mix) and
|
||||
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
|
||||
*/
|
||||
audiomuseNavidromeByServer: Record<string, boolean>;
|
||||
setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void;
|
||||
|
||||
/** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */
|
||||
subsonicServerIdentityByServer: Record<string, SubsonicServerIdentity>;
|
||||
setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void;
|
||||
|
||||
/** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */
|
||||
audiomuseNavidromeIssueByServer: Record<string, boolean>;
|
||||
setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void;
|
||||
|
||||
/**
|
||||
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection.
|
||||
*/
|
||||
instantMixProbeByServer: Record<string, InstantMixProbeResult>;
|
||||
setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
@@ -133,7 +160,7 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadCustomSeconds: (v: number) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
@@ -144,6 +171,7 @@ interface AuthState {
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setLyricsServerFirst: (v: boolean) => void;
|
||||
setEnableNeteaselyrics: (v: boolean) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
@@ -230,6 +258,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
useCustomTitlebar: false,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
enableNeteaselyrics: false,
|
||||
showFullscreenLyrics: true,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
@@ -250,6 +279,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 0,
|
||||
entityRatingSupportByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
subsonicServerIdentityByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -272,11 +305,19 @@ export const useAuthStore = create<AuthState>()(
|
||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||
const switchedAway = s.activeServerId === id;
|
||||
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
|
||||
const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||
const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer;
|
||||
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
|
||||
return {
|
||||
servers: newServers,
|
||||
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
|
||||
isLoggedIn: switchedAway ? false : s.isLoggedIn,
|
||||
entityRatingSupportByServer: entityRatingRest,
|
||||
audiomuseNavidromeByServer: audiomuseRest,
|
||||
subsonicServerIdentityByServer: identityRest,
|
||||
audiomuseNavidromeIssueByServer: issueRest,
|
||||
instantMixProbeByServer: probeRest,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -315,7 +356,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
@@ -326,6 +367,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
||||
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
@@ -403,6 +445,60 @@ export const useAuthStore = create<AuthState>()(
|
||||
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
|
||||
})),
|
||||
|
||||
setAudiomuseNavidromeEnabled: (serverId, enabled) =>
|
||||
set(s => {
|
||||
const audiomuseNavidromeByServer = enabled
|
||||
? { ...s.audiomuseNavidromeByServer, [serverId]: true }
|
||||
: (() => {
|
||||
const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer;
|
||||
return rest;
|
||||
})();
|
||||
const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest };
|
||||
}),
|
||||
|
||||
setSubsonicServerIdentity: (serverId, identity) =>
|
||||
set(s => {
|
||||
const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } };
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) {
|
||||
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
|
||||
return {
|
||||
subsonicServerIdentityByServer,
|
||||
audiomuseNavidromeByServer: audiomuseRest,
|
||||
audiomuseNavidromeIssueByServer: issueRest,
|
||||
instantMixProbeByServer: probeRest,
|
||||
};
|
||||
}
|
||||
return { subsonicServerIdentityByServer };
|
||||
}),
|
||||
|
||||
setInstantMixProbe: (serverId, result) =>
|
||||
set(s => {
|
||||
const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result };
|
||||
if (result === 'empty') {
|
||||
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
return {
|
||||
instantMixProbeByServer,
|
||||
audiomuseNavidromeByServer: audiomuseRest,
|
||||
audiomuseNavidromeIssueByServer: issueRest,
|
||||
};
|
||||
}
|
||||
return { instantMixProbeByServer };
|
||||
}),
|
||||
|
||||
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
|
||||
set(s =>
|
||||
hasIssue
|
||||
? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } }
|
||||
: (() => {
|
||||
const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer;
|
||||
return { audiomuseNavidromeIssueByServer: rest };
|
||||
})(),
|
||||
),
|
||||
|
||||
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
|
||||
|
||||
getBaseUrl: () => {
|
||||
|
||||
+127
-54
@@ -117,6 +117,7 @@ interface PlayerState {
|
||||
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
|
||||
syncLastfmLovedTracks: () => Promise<void>;
|
||||
|
||||
resetAudioPause: () => void;
|
||||
initializeFromServerQueue: () => Promise<void>;
|
||||
|
||||
contextMenu: {
|
||||
@@ -203,6 +204,11 @@ radioAudio.preload = 'none';
|
||||
let radioStopping = false;
|
||||
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
|
||||
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Counts how many stalled-reconnects have been attempted for the current station.
|
||||
// Reset to 0 on successful playback. Hard-stop after MAX_RADIO_RECONNECTS so a
|
||||
// dead stream doesn't loop forever and leak resources in the background.
|
||||
let radioReconnectCount = 0;
|
||||
const MAX_RADIO_RECONNECTS = 5;
|
||||
|
||||
function clearRadioReconnectTimer() {
|
||||
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
|
||||
@@ -211,23 +217,40 @@ function clearRadioReconnectTimer() {
|
||||
radioAudio.addEventListener('ended', () => {
|
||||
// Stream disconnected unexpectedly — clear radio state.
|
||||
clearRadioReconnectTimer();
|
||||
radioReconnectCount = 0;
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||
});
|
||||
radioAudio.addEventListener('error', () => {
|
||||
clearRadioReconnectTimer();
|
||||
if (radioStopping) { radioStopping = false; return; }
|
||||
if (radioStopping) { radioStopping = false; radioReconnectCount = 0; return; }
|
||||
radioReconnectCount = 0;
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
});
|
||||
// Playing: stream is delivering audio — reset the reconnect counter.
|
||||
radioAudio.addEventListener('playing', () => {
|
||||
radioReconnectCount = 0;
|
||||
});
|
||||
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
|
||||
// On macOS/WKWebView, reassigning src during a stall can itself trigger
|
||||
// another stall event before the new connection is established. The
|
||||
// radioReconnectTimer guard prevents stacking, and MAX_RADIO_RECONNECTS
|
||||
// ensures we don't loop forever on a dead stream.
|
||||
radioAudio.addEventListener('stalled', () => {
|
||||
if (radioReconnectTimer) return; // already scheduled
|
||||
if (radioReconnectCount >= MAX_RADIO_RECONNECTS) {
|
||||
radioReconnectCount = 0;
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
|
||||
showToast('Radio stream disconnected', 4000, 'error');
|
||||
return;
|
||||
}
|
||||
radioReconnectTimer = setTimeout(() => {
|
||||
radioReconnectTimer = null;
|
||||
if (!usePlayerStore.getState().currentRadio) return;
|
||||
// Re-assign src to force a fresh connection, then resume playback.
|
||||
const src = radioAudio.src;
|
||||
radioAudio.src = src;
|
||||
radioReconnectCount++;
|
||||
// Use load() + play() instead of src reassignment — more reliable on
|
||||
// macOS WKWebView where setting src can fire a premature error event.
|
||||
radioAudio.load();
|
||||
radioAudio.play().catch(console.error);
|
||||
}, 4000);
|
||||
});
|
||||
@@ -249,10 +272,10 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||
}
|
||||
|
||||
// Track ID that has already been sent to audio_chain_preload / audio_preload.
|
||||
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
|
||||
// the last 30 seconds of a track, each spawning its own HTTP download.
|
||||
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
||||
let gaplessPreloadingId: string | null = null;
|
||||
// Track ID that has already been sent to audio_preload (byte pre-download).
|
||||
let bytePreloadingId: string | null = null;
|
||||
|
||||
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -306,48 +329,57 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
}
|
||||
|
||||
// Pre-buffer / pre-chain next track based on preload mode.
|
||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
|
||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled } = useAuthStore.getState();
|
||||
const remaining = dur - current_time;
|
||||
const shouldPreload = preloadMode === 'early'
|
||||
? current_time >= 5
|
||||
: preloadMode === 'custom'
|
||||
? remaining < preloadCustomSeconds && remaining > 0
|
||||
: remaining < 30 && remaining > 0; // balanced (default)
|
||||
if (shouldPreload) {
|
||||
|
||||
// Gapless chain: always triggers at 30s regardless of preloadMode.
|
||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
|
||||
const shouldBytePreload = !hotCacheEnabled && preloadMode !== 'off' && (
|
||||
preloadMode === 'early'
|
||||
? current_time >= 5
|
||||
: preloadMode === 'custom'
|
||||
? remaining < preloadCustomSeconds && remaining > 0
|
||||
: remaining < 30 && remaining > 0 // balanced (default)
|
||||
);
|
||||
|
||||
if (shouldChainGapless || shouldBytePreload) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||
if (shouldBytePreload && nextTrack.id !== bytePreloadingId) {
|
||||
bytePreloadingId = nextTrack.id;
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
|
||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
|
||||
gaplessPreloadingId = nextTrack.id;
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
if (gaplessEnabled) {
|
||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
||||
// advance. By the time the track boundary arrives, the next source is
|
||||
// already live — no IPC round-trip at the gap point.
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? nextTrack.replayGainAlbumDb
|
||||
: nextTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? (nextTrack.replayGainAlbumDb ?? nextTrack.replayGainTrackDb)
|
||||
: nextTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,7 +417,7 @@ function handleAudioEnded() {
|
||||
*/
|
||||
function handleAudioTrackSwitched(duration: number) {
|
||||
lastGaplessSwitchTime = Date.now();
|
||||
gaplessPreloadingId = null; // allow preloading for the track after this one
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // allow preloading for the track after this one
|
||||
isAudioPaused = false;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
@@ -460,11 +492,28 @@ function handleAudioError(message: string) {
|
||||
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||
*/
|
||||
export function initAudioListeners(): () => void {
|
||||
// Dev-only: warn when audio:progress events arrive faster than 10/s.
|
||||
// This would indicate the Rust emit interval was accidentally lowered.
|
||||
let _devEventCount = 0;
|
||||
let _devWindowStart = 0;
|
||||
|
||||
const pending = [
|
||||
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
|
||||
handleAudioProgress(payload.current_time, payload.duration)
|
||||
),
|
||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
|
||||
if (import.meta.env.DEV) {
|
||||
_devEventCount++;
|
||||
const now = Date.now();
|
||||
if (_devWindowStart === 0) _devWindowStart = now;
|
||||
if (now - _devWindowStart >= 1000) {
|
||||
if (_devEventCount > 10) {
|
||||
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
|
||||
}
|
||||
_devEventCount = 0;
|
||||
_devWindowStart = now;
|
||||
}
|
||||
}
|
||||
handleAudioProgress(payload.current_time, payload.duration);
|
||||
}),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
@@ -521,6 +570,7 @@ export function initAudioListeners(): () => void {
|
||||
playing: isPlaying,
|
||||
positionSecs: currentTime > 0 ? currentTime : null,
|
||||
}).catch(() => {});
|
||||
invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,7 +769,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
++playGeneration;
|
||||
isAudioPaused = false;
|
||||
clearRadioReconnectTimer();
|
||||
gaplessPreloadingId = null;
|
||||
radioReconnectCount = 0;
|
||||
gaplessPreloadingId = null; bytePreloadingId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
invoke('audio_stop').catch(() => {});
|
||||
@@ -754,7 +805,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
const gen = ++playGeneration;
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null; // new track — allow fresh preload for next
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
@@ -789,7 +840,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
setDeferHotCachePrefetch(true);
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
|
||||
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
@@ -839,6 +890,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
resetAudioPause: () => {
|
||||
isAudioPaused = false;
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
if (get().currentRadio) {
|
||||
radioAudio.play().catch(console.error);
|
||||
@@ -867,7 +922,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (freshSong) set({ currentTrack: trackToPlay });
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = authStateCold.replayGainEnabled
|
||||
? (authStateCold.replayGainMode === 'album' ? trackToPlay.replayGainAlbumDb : trackToPlay.replayGainTrackDb) ?? null
|
||||
? (authStateCold.replayGainMode === 'album' ? (trackToPlay.replayGainAlbumDb ?? trackToPlay.replayGainTrackDb) : trackToPlay.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
@@ -973,7 +1028,19 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
.slice(0, 10)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (fresh.length > 0) {
|
||||
set(state => ({ queue: [...state.queue, ...fresh] }));
|
||||
// Trim played tracks from the front to keep the queue bounded.
|
||||
// Without trimming the queue grows unboundedly, making every
|
||||
// Zustand persist write larger and causing UI freezes over time.
|
||||
// Keep the last HISTORY_KEEP played tracks so the user can still
|
||||
// navigate backwards a few songs.
|
||||
const HISTORY_KEEP = 5;
|
||||
set(state => {
|
||||
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
|
||||
return {
|
||||
queue: [...state.queue.slice(trimStart), ...fresh],
|
||||
queueIndex: state.queueIndex - trimStart,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -1230,8 +1297,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (!currentTrack || !currentTrack.id) return;
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? currentTrack.replayGainAlbumDb
|
||||
? (authState.replayGainMode === 'album'
|
||||
? (currentTrack.replayGainAlbumDb ?? currentTrack.replayGainTrackDb)
|
||||
: currentTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
@@ -1254,7 +1321,13 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTrack: state.currentTrack,
|
||||
queue: state.queue,
|
||||
queueIndex: state.queueIndex,
|
||||
currentTime: state.currentTime,
|
||||
// currentTime is intentionally NOT persisted here.
|
||||
// handleAudioProgress fires every 100ms and each setState with a
|
||||
// persisted field triggers a full JSON serialisation of the queue to
|
||||
// localStorage. After ~10 minutes of Artist Radio the queue grows to
|
||||
// 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's
|
||||
// storage process to crash on macOS → black screen + audio stop.
|
||||
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
|
||||
lastfmLovedCache: state.lastfmLovedCache,
|
||||
}),
|
||||
}
|
||||
|
||||
+133
-31
@@ -21,17 +21,7 @@
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
|
||||
transform: scale(1.05);
|
||||
filter: blur(12px);
|
||||
}
|
||||
|
||||
.hero:hover .hero-bg {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.hero:hover .hero-bg {
|
||||
transform: scale(1);
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
|
||||
.hero-dots {
|
||||
@@ -909,11 +899,8 @@
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(20px) brightness(0.6);
|
||||
transform: scale(1.1);
|
||||
/* Hide blur edges */
|
||||
z-index: 0;
|
||||
opacity: 0.8;
|
||||
opacity: 0.35;
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
|
||||
@@ -1148,6 +1135,8 @@
|
||||
|
||||
.album-detail-badge {
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.album-detail-back {
|
||||
@@ -3014,23 +3003,22 @@
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */
|
||||
@keyframes mesh-aura-a {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
33% { transform: translate3d(5%, -6%, 0); }
|
||||
66% { transform: translate3d(-3%, 2%, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
0% { transform: translate(0, 0 ); }
|
||||
33% { transform: translate(5%, -6% ); }
|
||||
66% { transform: translate(-3%, 2% ); }
|
||||
100% { transform: translate(0, 0 ); }
|
||||
}
|
||||
|
||||
@keyframes mesh-aura-b {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(-6%, 5%, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
0% { transform: translate(0, 0 ); }
|
||||
50% { transform: translate(-6%, 5% ); }
|
||||
100% { transform: translate(0, 0 ); }
|
||||
}
|
||||
|
||||
@keyframes portrait-drift {
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(0, -10px, 0); }
|
||||
0%, 100% { transform: translateY(0 ); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.fs-mesh-bg {
|
||||
@@ -3042,12 +3030,11 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blobs are real divs so will-change: transform applies (pseudo-elements can't have it) */
|
||||
/* Blobs are real divs — no will-change/filter to avoid software compositing layers */
|
||||
.fs-mesh-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.fs-mesh-blob-a {
|
||||
@@ -3058,8 +3045,8 @@
|
||||
bottom: -35%;
|
||||
background: radial-gradient(ellipse,
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%,
|
||||
transparent 65%);
|
||||
filter: blur(55px);
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 96%) 45%,
|
||||
transparent 70%);
|
||||
animation: mesh-aura-a 26s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
transition: background 400ms ease-in-out;
|
||||
@@ -3072,8 +3059,8 @@
|
||||
top: -25%;
|
||||
background: radial-gradient(ellipse,
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%,
|
||||
transparent 65%);
|
||||
filter: blur(65px);
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 98%) 45%,
|
||||
transparent 70%);
|
||||
animation: mesh-aura-b 20s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
transition: background 400ms ease-in-out;
|
||||
@@ -4687,6 +4674,121 @@
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* ─ Radio NowPlaying section ─ */
|
||||
.np-radio-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.np-badge-live {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
color: #f87171;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.np-badge-azuracast {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.np-radio-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.np-radio-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.np-radio-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.np-radio-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.np-radio-next-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.np-radio-track-art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.np-radio-track-art--sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.np-radio-track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.np-radio-track-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.np-radio-track-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Radio progress bar in PlayerBar */
|
||||
.radio-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.radio-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
/* Listener count in PlayerBar */
|
||||
.player-radio-listeners {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Queue section */
|
||||
.np-queue-section {
|
||||
flex: 1;
|
||||
|
||||
+173
-11
@@ -13,16 +13,22 @@
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--queue-width);
|
||||
grid-template-rows: 1fr var(--player-height);
|
||||
/* minmax(0, 1fr) instead of plain 1fr: CSS Grid spec treats 1fr as
|
||||
minmax(auto, 1fr), meaning the row won't shrink below its min-content
|
||||
size. With minmax(0, 1fr) the row can collapse to zero, keeping the
|
||||
player bar row always visible regardless of content size or zoom level. */
|
||||
grid-template-rows: minmax(0, 1fr) var(--player-height);
|
||||
grid-template-areas:
|
||||
"sidebar main queue"
|
||||
"player player player";
|
||||
height: 100vh;
|
||||
/* overflow: hidden keeps the player bar pinned at the bottom even when the
|
||||
window is dragged below the OS minHeight constraint (ignored on some
|
||||
Linux WMs/compositors). Without this, the 1fr row's implicit auto
|
||||
min-height can push the grid taller than 100vh, scrolling the player
|
||||
bar out of view. */
|
||||
/* height: 100% inherits from html/body/#root (all 100%) so the grid
|
||||
tracks actual window resize events on every platform.
|
||||
100vh was used previously but WebView2 (Windows) does not update the
|
||||
CSS viewport synchronously on resize, causing the player-bar grid row
|
||||
to drift off-screen. overflow: hidden still prevents the 1fr row from
|
||||
pushing the grid taller than the window on Linux WMs that ignore
|
||||
the OS minHeight constraint. */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
@@ -33,7 +39,7 @@
|
||||
}
|
||||
|
||||
.app-shell[data-titlebar] {
|
||||
grid-template-rows: var(--titlebar-height) 1fr var(--player-height);
|
||||
grid-template-rows: var(--titlebar-height) minmax(0, 1fr) var(--player-height);
|
||||
grid-template-areas:
|
||||
"titlebar titlebar titlebar"
|
||||
"sidebar main queue"
|
||||
@@ -154,6 +160,10 @@
|
||||
|
||||
.resizer-queue { right: calc(var(--queue-width) - 3px); }
|
||||
|
||||
/* In native fullscreen the resizer sits flush against the screen edge and
|
||||
would interfere with OS edge-swipe gestures — hide it entirely. */
|
||||
.app-shell[data-fullscreen] .resizer { display: none; }
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
@@ -651,6 +661,157 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Update Modal ────────────────────────────────────────────────────────── */
|
||||
.update-modal {
|
||||
width: min(520px, 94vw);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.update-modal-header {
|
||||
padding: 14px 16px 14px 18px;
|
||||
}
|
||||
.update-modal-versions {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* Scrollable middle section (changelog + download area combined) */
|
||||
.update-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Changelog accordion */
|
||||
.update-modal-changelog {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.update-modal-changelog-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 9px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.update-modal-changelog-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
.update-modal-changelog-body {
|
||||
padding: 4px 18px 12px;
|
||||
}
|
||||
/* Download area */
|
||||
.update-modal-download-area {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.update-modal-asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.update-modal-asset-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.update-modal-asset-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.update-modal-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-progress .app-updater-progress-bar { flex: 1; height: 4px; }
|
||||
.update-modal-dl-bytes {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.update-modal-done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-done-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.update-modal-done-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.update-modal-folder-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* AUR hint */
|
||||
.update-modal-aur {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-aur-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.update-modal-aur-cmd {
|
||||
display: block;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
}
|
||||
.update-modal-aur-alt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.update-modal-asset-none {
|
||||
padding: 4px 0;
|
||||
}
|
||||
/* Footer */
|
||||
.update-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.update-modal-skip {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
.update-modal-skip:hover { color: var(--text-secondary); }
|
||||
|
||||
.update-toast {
|
||||
margin: 0 var(--space-1) var(--space-2);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
@@ -927,6 +1088,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.player-album-art-wrap::after {
|
||||
content: '';
|
||||
@@ -1593,7 +1755,7 @@
|
||||
/* ─── Grid Overrides ─── */
|
||||
.app-shell[data-mobile] {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
grid-template-rows: minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"main"
|
||||
"bottomnav"
|
||||
@@ -1603,7 +1765,7 @@
|
||||
}
|
||||
|
||||
.app-shell[data-mobile][data-titlebar] {
|
||||
grid-template-rows: var(--titlebar-height) 1fr auto auto;
|
||||
grid-template-rows: var(--titlebar-height) minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"titlebar"
|
||||
"main"
|
||||
@@ -1678,7 +1840,7 @@
|
||||
/* When the mobile player is active, the app-shell becomes a single full-screen
|
||||
area with no BottomNav or PlayerBar — just the main content. */
|
||||
.app-shell[data-mobile-player] {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-areas: "main";
|
||||
}
|
||||
|
||||
|
||||
+133
-48
@@ -3,7 +3,22 @@
|
||||
Catppuccin Mocha Color Palette + Inter Typography
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@400;500;600;700&family=Outfit:wght@300..700&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Nunito:ital,wght@0,300..800;1,300..800&family=Rubik:ital,wght@0,300..700;1,300..700&family=Figtree:ital,wght@0,300..800;1,300..800&family=Manrope:wght@300..800&family=Plus+Jakarta+Sans:ital,wght@0,300..800;1,300..800&family=Lexend:wght@300..700&display=swap');
|
||||
@import '@fontsource-variable/inter';
|
||||
@import '@fontsource-variable/inter/wght-italic.css';
|
||||
@import '@fontsource-variable/space-grotesk';
|
||||
@import '@fontsource-variable/outfit';
|
||||
@import '@fontsource-variable/dm-sans';
|
||||
@import '@fontsource-variable/dm-sans/wght-italic.css';
|
||||
@import '@fontsource-variable/nunito';
|
||||
@import '@fontsource-variable/nunito/wght-italic.css';
|
||||
@import '@fontsource-variable/rubik';
|
||||
@import '@fontsource-variable/rubik/wght-italic.css';
|
||||
@import '@fontsource-variable/figtree';
|
||||
@import '@fontsource-variable/figtree/wght-italic.css';
|
||||
@import '@fontsource-variable/manrope';
|
||||
@import '@fontsource-variable/plus-jakarta-sans';
|
||||
@import '@fontsource-variable/plus-jakarta-sans/wght-italic.css';
|
||||
@import '@fontsource-variable/lexend';
|
||||
|
||||
/* ─── Catppuccin Mocha Variables ─── */
|
||||
:root,
|
||||
@@ -3129,8 +3144,8 @@
|
||||
/* ─── Global Base Settings ─── */
|
||||
:root {
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk', 'Inter', sans-serif;
|
||||
--font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk Variable', 'Inter Variable', sans-serif;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 4px;
|
||||
@@ -3499,8 +3514,9 @@ html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
min-width: 1280px;
|
||||
min-height: 720px;
|
||||
/* min-height removed: Tauri enforces the real window minimum (minHeight: 480).
|
||||
A CSS min-height larger than the effective CSS viewport (which shrinks at
|
||||
zoom > 1) pushes html taller than the window, clipping the player bar. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -6303,49 +6319,54 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
}
|
||||
|
||||
/* ─── Font overrides ─── */
|
||||
[data-font='inter'] {
|
||||
--font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Inter Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='outfit'] {
|
||||
--font-sans: 'Outfit', system-ui, sans-serif;
|
||||
--font-display: 'Outfit', sans-serif;
|
||||
--font-sans: 'Outfit Variable', system-ui, sans-serif;
|
||||
--font-display: 'Outfit Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='dm-sans'] {
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
--font-display: 'DM Sans', sans-serif;
|
||||
--font-sans: 'DM Sans Variable', system-ui, sans-serif;
|
||||
--font-display: 'DM Sans Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='nunito'] {
|
||||
--font-sans: 'Nunito', system-ui, sans-serif;
|
||||
--font-display: 'Nunito', sans-serif;
|
||||
--font-sans: 'Nunito Variable', system-ui, sans-serif;
|
||||
--font-display: 'Nunito Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='rubik'] {
|
||||
--font-sans: 'Rubik', system-ui, sans-serif;
|
||||
--font-display: 'Rubik', sans-serif;
|
||||
--font-sans: 'Rubik Variable', system-ui, sans-serif;
|
||||
--font-display: 'Rubik Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='space-grotesk'] {
|
||||
--font-sans: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-display: 'Space Grotesk', sans-serif;
|
||||
--font-sans: 'Space Grotesk Variable', system-ui, sans-serif;
|
||||
--font-display: 'Space Grotesk Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='figtree'] {
|
||||
--font-sans: 'Figtree', system-ui, sans-serif;
|
||||
--font-display: 'Figtree', sans-serif;
|
||||
--font-sans: 'Figtree Variable', system-ui, sans-serif;
|
||||
--font-display: 'Figtree Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='manrope'] {
|
||||
--font-sans: 'Manrope', system-ui, sans-serif;
|
||||
--font-display: 'Manrope', sans-serif;
|
||||
--font-sans: 'Manrope Variable', system-ui, sans-serif;
|
||||
--font-display: 'Manrope Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='plus-jakarta-sans'] {
|
||||
--font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
--font-display: 'Plus Jakarta Sans', sans-serif;
|
||||
--font-sans: 'Plus Jakarta Sans Variable', system-ui, sans-serif;
|
||||
--font-display: 'Plus Jakarta Sans Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='lexend'] {
|
||||
--font-sans: 'Lexend', system-ui, sans-serif;
|
||||
--font-display: 'Lexend', sans-serif;
|
||||
--font-sans: 'Lexend Variable', system-ui, sans-serif;
|
||||
--font-display: 'Lexend Variable', sans-serif;
|
||||
}
|
||||
|
||||
/* ─── Equalizer bars animation ─── */
|
||||
@@ -6495,11 +6516,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Old Shire craftsmanship — vertical wood grain, warm hearthlight from right edge */
|
||||
[data-theme='middle-earth'] .sidebar {
|
||||
background-color: #221608;
|
||||
background-image:
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(255, 220, 120, 0.04) 0px, rgba(255, 220, 120, 0.04) 1px,
|
||||
transparent 1px, transparent 14px),
|
||||
linear-gradient(180deg, #261a0a 0%, #1e1408 55%, #241c0e 100%);
|
||||
background-image: linear-gradient(180deg, #261a0a 0%, #1e1408 55%, #241c0e 100%);
|
||||
border-right: 2px solid rgba(200, 160, 40, 0.28);
|
||||
box-shadow:
|
||||
inset -3px 0 12px rgba(200, 140, 30, 0.10),
|
||||
@@ -6961,6 +6978,40 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #7a5a30;
|
||||
}
|
||||
|
||||
/* Queue: "Nächste Titel" divider label */
|
||||
[data-theme='middle-earth'] .queue-divider span {
|
||||
color: #a07840 !important;
|
||||
}
|
||||
|
||||
/* Queue: artist text visible on hover for inactive items */
|
||||
[data-theme='middle-earth'] .queue-item:hover .queue-item-artist,
|
||||
[data-theme='middle-earth'] .queue-item.context-active .queue-item-artist {
|
||||
color: #e8c878;
|
||||
}
|
||||
|
||||
/* AlbumDetail header: artist name readable */
|
||||
[data-theme='middle-earth'] .album-detail-artist,
|
||||
[data-theme='middle-earth'] .album-detail-artist-link {
|
||||
color: #e8c878;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .album-detail-artist-link:hover {
|
||||
color: #f8e060;
|
||||
}
|
||||
|
||||
/* ArtistDetail About: heading, bio text + "Mehr lesen" readable */
|
||||
[data-theme='middle-earth'] .np-card-title {
|
||||
color: #6b4e2a;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .np-bio-text {
|
||||
color: #5a3e20;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .np-bio-toggle {
|
||||
color: #7a5228;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Morpheus — Matrix Style
|
||||
───────────────────────────────────────────────────────────── */
|
||||
@@ -8260,19 +8311,19 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--ctp-teal: #00f0f0;
|
||||
--ctp-yellow: #f0d000;
|
||||
/* O-piece — yellow */
|
||||
--ctp-mauve: #a020f0;
|
||||
--ctp-mauve: #c070ff;
|
||||
/* T-piece — purple */
|
||||
--ctp-lavender: #a020f0;
|
||||
--ctp-pink: #a020f0;
|
||||
--ctp-lavender: #c070ff;
|
||||
--ctp-pink: #c070ff;
|
||||
--ctp-green: #00d000;
|
||||
/* S-piece — green */
|
||||
--ctp-red: #f02000;
|
||||
/* Z-piece — red */
|
||||
--ctp-flamingo: #f02000;
|
||||
--ctp-maroon: #f02000;
|
||||
--ctp-blue: #0060f0;
|
||||
--ctp-blue: #4090ff;
|
||||
/* J-piece — blue */
|
||||
--ctp-sapphire: #0060f0;
|
||||
--ctp-sapphire: #4090ff;
|
||||
--ctp-peach: #f07800;
|
||||
/* L-piece — orange */
|
||||
--ctp-rosewater: #f07800;
|
||||
@@ -8294,7 +8345,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #f0d000;
|
||||
/* O-piece yellow — like the score readout */
|
||||
--text-muted: #3a3a6a;
|
||||
--text-muted: #7878b8;
|
||||
--border: #1e1e48;
|
||||
--border-subtle: #0d0d22;
|
||||
|
||||
@@ -8322,7 +8373,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
[data-theme='tetrastack'] .player-bar {
|
||||
border-top: 3px solid #00f0f0;
|
||||
box-shadow:
|
||||
0 -2px 0 #a020f0,
|
||||
0 -2px 0 #c070ff,
|
||||
0 -8px 24px rgba(0, 240, 240, 0.12);
|
||||
}
|
||||
|
||||
@@ -8374,7 +8425,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
|
||||
/* Album title in queue = T-piece purple */
|
||||
[data-theme='tetrastack'] .queue-meta-album {
|
||||
color: #a020f0;
|
||||
color: #c070ff;
|
||||
}
|
||||
|
||||
/* Active queue item = I-piece left border */
|
||||
@@ -8452,8 +8503,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Artist pill links = J-piece blue */
|
||||
[data-theme='tetrastack'] .artist-ext-link:hover {
|
||||
background: rgba(0, 96, 240, 0.1);
|
||||
color: #0060f0 !important;
|
||||
border-color: rgba(0, 96, 240, 0.4) !important;
|
||||
color: #4090ff !important;
|
||||
border-color: rgba(64, 144, 255, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Now playing active track = Z-piece red accent */
|
||||
@@ -8469,6 +8520,26 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #f0d000;
|
||||
}
|
||||
|
||||
/* Sidebar section labels */
|
||||
[data-theme='tetrastack'] .nav-section-label {
|
||||
color: rgba(0, 240, 240, 0.45);
|
||||
}
|
||||
|
||||
/* Queue tab buttons (Lyrics / Warteschlange) — inactive */
|
||||
[data-theme='tetrastack'] .queue-tab-btn {
|
||||
color: #6060a8;
|
||||
}
|
||||
|
||||
/* Queue: inactive song artist */
|
||||
[data-theme='tetrastack'] .queue-item-artist {
|
||||
color: #6060a8;
|
||||
}
|
||||
|
||||
/* Queue: "Nächste Titel" divider */
|
||||
[data-theme='tetrastack'] .queue-divider span {
|
||||
color: #6060a8 !important;
|
||||
}
|
||||
|
||||
/* ── The Book (Social Media) ────────────────────────────────── */
|
||||
[data-theme='the-book'] {
|
||||
color-scheme: light;
|
||||
@@ -12117,11 +12188,6 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Sidebar — iron plate lines + subtle red glow on active */
|
||||
[data-theme='horde'] .sidebar {
|
||||
background-color: #110300;
|
||||
background-image: repeating-linear-gradient(180deg,
|
||||
transparent 0px,
|
||||
transparent 39px,
|
||||
rgba(100, 20, 0, 0.18) 39px,
|
||||
rgba(100, 20, 0, 0.18) 40px);
|
||||
border-right: 1px solid rgba(140, 40, 10, 0.35);
|
||||
}
|
||||
|
||||
@@ -12241,11 +12307,6 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Sidebar — cathedral stone columns with gold right-border */
|
||||
[data-theme='alliance'] .sidebar {
|
||||
background-color: #040c18;
|
||||
background-image: repeating-linear-gradient(180deg,
|
||||
transparent 0px,
|
||||
transparent 47px,
|
||||
rgba(60, 100, 180, 0.14) 47px,
|
||||
rgba(60, 100, 180, 0.14) 48px);
|
||||
border-right: 2px solid rgba(200, 170, 50, 0.30);
|
||||
}
|
||||
|
||||
@@ -12825,6 +12886,30 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #6a5030;
|
||||
}
|
||||
|
||||
/* Sidebar section labels (sky-blue bg needs light text) */
|
||||
[data-theme='toy-tale'] .nav-section-label {
|
||||
color: rgba(200, 230, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Queue tab buttons (Lyrics / Warteschlange) — inactive */
|
||||
[data-theme='toy-tale'] .queue-tab-btn {
|
||||
color: #b89060;
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .queue-tab-btn:hover {
|
||||
color: #f0e8d8;
|
||||
}
|
||||
|
||||
/* Queue: inactive song artist */
|
||||
[data-theme='toy-tale'] .queue-item-artist {
|
||||
color: #9a7848;
|
||||
}
|
||||
|
||||
/* Queue: "Nächste Titel" divider */
|
||||
[data-theme='toy-tale'] .queue-divider span {
|
||||
color: #b89060 !important;
|
||||
}
|
||||
|
||||
/* ─── North Park — South Park ─── */
|
||||
/* Construction paper flat aesthetic — paper-cream main, Colorado mountain-blue sidebar,
|
||||
Kenny orange accent, South Park sky blue player accents. Light, no dark mode. */
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import i18n from '../i18n';
|
||||
|
||||
/** Totals / statistics: localized "N hours M minutes" (not track mm:ss). */
|
||||
export function formatHumanHoursMinutes(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) {
|
||||
return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m });
|
||||
}
|
||||
return i18n.t('common.durationMinutesOnly', { minutes: m });
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
/** True when running on Linux (WebKitGTK). Used to show the custom title bar. */
|
||||
export const IS_LINUX = navigator.platform.toLowerCase().includes('linux');
|
||||
/** True when running on macOS (WKWebView). */
|
||||
export const IS_MACOS = navigator.platform.toLowerCase().includes('mac');
|
||||
/** True when running on Windows (WebView2). */
|
||||
export const IS_WINDOWS = navigator.platform.toLowerCase().includes('win');
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Fields from Subsonic `ping` / any `subsonic-response` root (Navidrome sets type + serverVersion). */
|
||||
export type SubsonicServerIdentity = {
|
||||
type?: string;
|
||||
serverVersion?: string;
|
||||
openSubsonic?: boolean;
|
||||
};
|
||||
|
||||
/** Result of `getRandomSongs` + `getSimilarSongs` probe (Instant Mix / agent chain). */
|
||||
export type InstantMixProbeResult = 'ok' | 'empty' | 'error' | 'skipped';
|
||||
|
||||
const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0];
|
||||
|
||||
function parseLeadingSemver(version: string | undefined): [number, number, number] | null {
|
||||
if (!version) return null;
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function semverGte(a: [number, number, number], b: [number, number, number]): boolean {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true;
|
||||
if (a[i] < b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive
|
||||
* until the first successful ping with metadata.
|
||||
*/
|
||||
export function isNavidromeAudiomuseSoftwareEligible(identity: SubsonicServerIdentity | undefined): boolean {
|
||||
if (!identity?.type?.trim()) return true;
|
||||
const t = identity.type.trim().toLowerCase();
|
||||
if (t !== 'navidrome') return false;
|
||||
const parsed = parseLeadingSemver(identity.serverVersion);
|
||||
if (!parsed) return true;
|
||||
return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the per-server AudioMuse (Navidrome plugin) toggle in Settings.
|
||||
* Uses software eligibility from ping plus an optional Instant Mix probe: if the server returns no
|
||||
* similar tracks for several random songs, the row stays hidden (typical when no plugin / no agents).
|
||||
*/
|
||||
export function showAudiomuseNavidromeServerSetting(
|
||||
identity: SubsonicServerIdentity | undefined,
|
||||
instantMixProbe: InstantMixProbeResult | undefined,
|
||||
): boolean {
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return false;
|
||||
if (instantMixProbe === 'empty') return false;
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user