mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-26 17:17:40 +00:00
Compare commits
82 Commits
v1.34.10
...
app-v1.34.12
| Author | SHA1 | Date | |
|---|---|---|---|
| e1b807d0ef | |||
| d18646de23 | |||
| ccdb899833 | |||
| b197694804 | |||
| b45bb95522 | |||
| cd1c785e43 | |||
| 1e54946939 | |||
| 34e374cf03 | |||
| 9387ceca83 | |||
| c165669db5 | |||
| c50addaacf | |||
| 7e64ef8f2a | |||
| 8c87ba95ff | |||
| 54962a15c1 | |||
| 689d2dc019 | |||
| f3fb730fbc | |||
| 5576fb0d13 | |||
| 99c6720b86 | |||
| 7b20965c1b | |||
| 28844e8456 | |||
| 572eb81927 | |||
| b6812de26b | |||
| 2f1d52e100 | |||
| 06902acd4b | |||
| 2deaed50b8 | |||
| 3ac82595f9 | |||
| 7db42d74ef | |||
| 58851da817 | |||
| 3262458cd3 | |||
| bf3d896016 | |||
| 442ed9d191 | |||
| e23280c013 | |||
| 189bb519eb | |||
| c573784fbb | |||
| 03b56afc92 | |||
| f437017359 | |||
| 34acc46c12 | |||
| d248a78acc | |||
| a4d8dd41fd | |||
| b7a2e91a62 | |||
| d519042917 | |||
| ccbf3605a0 | |||
| fce8dc3208 | |||
| 2384b73908 | |||
| 7cd0eaf9e1 | |||
| 9383a6f6ed | |||
| 624bd6bff7 | |||
| 67f02def43 | |||
| 1a2bc3f65b | |||
| 56d025f150 | |||
| 602811908b | |||
| fc90617e9c | |||
| a44581e5d6 | |||
| eb061bd9de | |||
| 6533991e7d | |||
| c75297fcf6 | |||
| 19b7c8ec06 | |||
| c3ebc9b951 | |||
| 117be0d85e | |||
| 12818e02f8 | |||
| 7ceaf4d5f2 | |||
| 76da684c8d | |||
| 795701a447 | |||
| 5340d696cf | |||
| 0a14123a0b | |||
| 7cd03248c8 | |||
| 4c6fc4d6d5 | |||
| 6996dacdf7 | |||
| 798812d5ef | |||
| 9f4c7382aa | |||
| d34de09673 | |||
| 1ff4f1ef0f | |||
| 9a9be253e3 | |||
| 38e59d7a5e | |||
| 915f0143f7 | |||
| e0bb02b65e | |||
| 0d5011c5be | |||
| 34d899806a | |||
| 928cdba17a | |||
| 17a5c92174 | |||
| 58f99ae846 | |||
| ef9a620ac6 |
@@ -125,7 +125,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev squashfs-tools
|
||||
libasound2-dev squashfs-tools cmake
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
+98
-3
@@ -5,6 +5,101 @@ 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).
|
||||
|
||||
> **⚠️ Note for Windows users:** This is the last release with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
|
||||
|
||||
## [1.34.12] - 2026-04-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Playback source indicator in Queue** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#201](https://github.com/Psychotoxical/psysonic/pull/201))*: The current-track tech strip in the Queue panel now shows a **source badge** indicating how the track was loaded: `stream` (live from server), `preloaded` (buffered before playback), or `cache` (served from local hot cache). Preload tracking is wired through the Rust audio engine so the badge reflects actual playback origin, not just current state.
|
||||
|
||||
- **ReplayGain metadata in Queue tech strip** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#196](https://github.com/Psychotoxical/psysonic/pull/196))*: The current-track tech strip now shows track and album ReplayGain values alongside bitrate and format when the file contains gain tags.
|
||||
|
||||
- **Discord Rich Presence enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#198](https://github.com/Psychotoxical/psysonic/pull/198))*: Discord Rich Presence received several improvements: dead/unused fields removed, the `{paused}` placeholder that Discord does not support was dropped, and a `timeChanged` invoke loop that fired redundantly on every progress tick was eliminated. The DRP timer is now accurate and stable.
|
||||
|
||||
- **Context menu in Search results** *(contributed by [@kveld9](https://github.com/kveld9), PR [#191](https://github.com/Psychotoxical/psysonic/pull/191))*: Song rows in the Search panel now support the full right-click context menu (Play, Queue, Playlist, etc.) — previously search results were click-only with no context actions.
|
||||
|
||||
- **Spotify CSV playlist import** *(contributed by [@kveld9](https://github.com/kveld9), PR [#190](https://github.com/Psychotoxical/psysonic/pull/190))*: Playlists exported from Spotify as CSV can now be imported directly into Psysonic. Tracks are matched by ISRC when available, with title/artist fallback. Unmatched tracks are listed in a report after import. Duplicate checking is done before writing.
|
||||
|
||||
- **CLI completions and expanded player controls** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#187](https://github.com/Psychotoxical/psysonic/pull/187))*: The `psysonic` CLI gains shell completions for bash/fish/zsh/elvish, new subcommands for library browsing and audio device listing, a server switcher command, and an opaque play-ID scheme for stable track references. The tray icon on Linux no longer requires `libayatana-appindicator` / `libindicator` — it falls back gracefully when the library is absent.
|
||||
|
||||
- **Albums and Playlists header redesign** *(contributed by [@kveld9](https://github.com/kveld9), PR [#186](https://github.com/Psychotoxical/psysonic/pull/186))*: The header sections on the Albums and Playlists pages have been redesigned for a cleaner, more consistent layout.
|
||||
|
||||
- **Favorites page redesign** *(contributed by [@kveld9](https://github.com/kveld9), PR [#184](https://github.com/Psychotoxical/psysonic/pull/184))*: The Favorites page has been overhauled with sortable columns, a gender filter, an age range filter, and additional metadata columns.
|
||||
|
||||
- **Split Mix navigation mode** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A new toggle in Settings switches the Mix section between a single **Build a Mix** hub entry and **two separate sidebar entries** — Random Mix and Random Albums — for users who prefer direct access. Navigation items are now defined in `src/config/navItems.ts`; the toggle is stored as `randomNavMode` in authStore.
|
||||
|
||||
- **Device Sync improvements** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Device Sync received several updates: a JSON manifest is now written to the device root on every sync (and read back automatically when the device is mounted); a **Cancel** button interrupts a running sync cleanly; a font picker was added to the sync page; sync status display was fixed; and the filename template builder now works correctly on all platforms.
|
||||
|
||||
- **Radio — ICY StreamTitle forwarded to MPRIS** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: While playing internet radio, the current song title parsed from ICY `StreamTitle` metadata is now forwarded to MPRIS `xesam:title` on Linux so that the track name appears in desktop notification shells and media controls.
|
||||
|
||||
- **Help page — expanded coverage** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Added missing help sections covering Device Sync, Internet Radio, CLI usage, Playlists, Infinite Queue, Lyrics sources, Audio device selection, Backup & Restore, and Now Playing details.
|
||||
|
||||
- **Tracklist column reset and privacy policy** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A reset button in the tracklist column picker restores the default column set. The Device Sync page received a cross-platform filename template fix. A privacy policy page was added documenting data usage for Last.fm, LRCLIB, NetEase, and Discord.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming playback stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#200](https://github.com/Psychotoxical/psysonic/pull/200))*: Several edge cases in the Rust audio engine around stream start, mid-track seeking, and track transitions were hardened. Cache promotion (moving a preloaded track into the hot cache) is now safer under concurrent access. Stream decoder errors during transitions no longer leave the engine in a stuck state.
|
||||
|
||||
- **CSV import reliability** *(contributed by [@kveld9](https://github.com/kveld9), PR [#199](https://github.com/Psychotoxical/psysonic/pull/199))*: The CSV import pipeline now guards the `ISRC` field type before calling `toUpperCase`, preventing a crash on rows with numeric or null ISRC values. The playlist public/private toggle in the edit modal (accidentally removed during a post-merge fix) is restored.
|
||||
|
||||
- **Tracklist column picker** *(contributed by [@kveld9](https://github.com/kveld9), PR [#188](https://github.com/Psychotoxical/psysonic/pull/188) and PR [#192](https://github.com/Psychotoxical/psysonic/pull/192))*: Fixed a column picker overflow where the dropdown was clipped by the tracklist container. Also fixed column toggle state and alignment issues in the picker UI. An `overflow-x: visible` regression introduced in PR #188 was subsequently reverted.
|
||||
|
||||
- **macOS — spurious microphone permission prompt** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Removed `NSMicrophoneUsageDescription` from `Info.plist`. It was inherited from an earlier Tauri template but Psysonic never uses the microphone; its presence caused macOS to show a permission dialog on first launch.
|
||||
|
||||
- **Device Sync — auto-import and disconnect cleanup** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The sync manifest is now automatically imported when the Device Sync page is opened if a device with a manifest is already mounted. The sync file list is cleared when the device is disconnected.
|
||||
|
||||
- **Audio — streaming decoder log labels** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#201](https://github.com/Psychotoxical/psysonic/pull/201))*: Rust log lines from the streaming decoder are now tagged with the source type, making it easier to distinguish stream vs. local decode paths in debug output.
|
||||
|
||||
- **Theme — Latte and GTA readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Improved contrast and text readability in the Catppuccin Latte and GTA themes.
|
||||
|
||||
- **i18n — missing `common.play` key** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Added the `common.play` translation key to all 8 locales; it was missing after PR #186 which introduced its usage.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Waveform seekbar — realtime waveform style** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The `realtime_waveform` CSS class and its associated style block were removed. This style was applied during live streaming and produced a low-quality rendering mode that was no longer needed after the streaming architecture improvements.
|
||||
|
||||
---
|
||||
|
||||
*Thank you to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for the playback source indicator, ReplayGain in the tech strip, streaming stability hardening, and CLI improvements — four substantial PRs.*
|
||||
*[@kveld9](https://github.com/kveld9) for the CSV import, search context menu, Discord RP enhancements, Favorites redesign, and header redesign — a very productive cycle.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.11] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Opus audio playback** *(Issue [#180](https://github.com/Psychotoxical/psysonic/issues/180), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#183](https://github.com/Psychotoxical/psysonic/pull/183))*: Psysonic can now decode Opus audio natively via `symphonia-adapter-libopus`, which bundles and compiles libopus from source. Previously `.opus` files were sent to the server for transcoding — a workaround that never worked reliably. Native decoding is now used directly; the server is no longer involved. Note: building from source requires `cmake` to be installed (see README).
|
||||
|
||||
- **Device Sync — synchronise your library to USB and SD card players** *(Issue [#161](https://github.com/Psychotoxical/psysonic/issues/161), by [@Psychotoxical](https://github.com/Psychotoxical))*: A fully overhauled Device Sync page lets you copy music from your Navidrome library to any mounted USB drive or SD card. Browse albums via live search (300 ms debounce) or a random album selection. Choose a filename template (Artist/Album/Track format), pick a target folder, and review a pre-sync summary showing files to add, files to delete, and available space — including a warning if the device would run out of space after accounting for pending deletions. Already-synced files are detected and skipped automatically so incremental syncs are fast.
|
||||
|
||||
- **3 visual toggles** *(contributed by [@kveld9](https://github.com/kveld9), PR [#181](https://github.com/Psychotoxical/psysonic/pull/181))*: Three new toggles in Settings → Appearance:
|
||||
- **Cover art background** — enables/disables the blurred album art background in Album Detail and the Hero section.
|
||||
- **Playlist cover photo** — shows/hides the cover collage at the top of Playlist Detail pages.
|
||||
- **Show bitrate badge** — toggles the bitrate label displayed on tracks in the queue and track lists.
|
||||
|
||||
- **8 community themes** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: A new **Community** theme group appears directly below Psysonic Themes in the Theme Picker, containing eight new themes: **AMOLED Black Pure** (pure black for OLED), **Monochrome Dark** (grayscale), **Amber Night** (warm golden amber), **Phosphor Green** (classic terminal green), **Midnight Blue** (deep blue), **Rose Dark** (pink/rose accents), **Sepia Dark** (warm cream sepia), and **Ice Blue** (cool cyan). Psysonic now ships with 75 themes across 9 groups.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HTTPS streaming failures and server URL trailing slash** *(Issue [#178](https://github.com/Psychotoxical/psysonic/issues/178), by [@Psychotoxical](https://github.com/Psychotoxical) with fix ported from PR [#179](https://github.com/Psychotoxical/psysonic/pull/179) by [@kveld9](https://github.com/kveld9))*: Two bugs that broke HTTPS server connections are now fixed. A trailing slash in the configured server URL caused double-slash stream URLs (`//rest/stream.view`) that reverse proxies like Caddy would reject, and also caused album browsing to return 0 results. Additionally, `reqwest` now loads the OS native certificate store alongside Mozilla's root store — fixing HTTPS streaming failures when the server certificate is signed by a local CA (e.g. Caddy's internal CA) that is trusted in the system keychain but not in Mozilla's bundle.
|
||||
|
||||
- **Server display in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The server list in Settings → Servers now shows the URL and username on separate lines instead of a single truncated `username@url` string. Protocol prefixes (`http://`, `https://`) are stripped for cleaner display. HTTPS connections show a green lock icon.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Waveform seekbar — live theme updates** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: The canvas-based seekbar now listens for `data-theme` attribute changes via `MutationObserver` and redraws immediately with the new theme colours. Switching themes no longer requires an app restart to update the waveform.
|
||||
|
||||
---
|
||||
|
||||
*Thank you to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for implementing native Opus decoding — a long-requested feature that finally makes `.opus` libraries fully playable.*
|
||||
*[@kveld9](https://github.com/kveld9) for three PRs in one release: the SSL/trailing-slash fix, visual customisation toggles, and eight new community themes with a live waveform update fix.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.10] - 2026-04-13
|
||||
|
||||
### Added
|
||||
@@ -17,7 +112,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Folder Browser — per-column filter & Shift+Enter queue append** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#165](https://github.com/Psychotoxical/psysonic/pull/165))*: Press Ctrl+F to open a filter field for the active Folder Browser column. Focus hands off cleanly between the filter input and the row list. Clearing a parent-column selection clears all right-side filters automatically. Press Shift+Enter on a filtered track list to **append** the visible tracks to the queue without replacing it.
|
||||
|
||||
- **Keybindings — in-app modifier chords** *(contributed by [@kveld9](https://github.com/kveld9), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 0–1 progress fraction.
|
||||
- **Keybindings — in-app modifier chords** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 0–1 progress fraction.
|
||||
|
||||
- **Playlist management enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#168](https://github.com/Psychotoxical/psysonic/pull/168))*: Multi-select context-menu actions for Albums, Artists, and Playlists now include a bulk **Add to Playlist** submenu. The sidebar playlist section is now collapsible. The Artists page gains infinite scroll via `IntersectionObserver`. Submenus flip upward automatically when they would overflow the viewport bottom. A **Remove from Playlist** entry is now available in the Playlist Detail context menu.
|
||||
|
||||
@@ -36,9 +131,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
---
|
||||
|
||||
*Special thanks to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for two significant Folder Browser improvements and the Linux audio stability fixes — three PRs in one release cycle, remarkable.*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for the significant Folder Browser improvements, the modifier-chord keybindings and and the Linux audio stability fixes — four PRs in one release cycle, remarkable.*
|
||||
*[@kilyabin](https://github.com/kilyabin) for continuing to hunt down no-compositing performance issues.*
|
||||
*[@kveld9](https://github.com/kveld9) for the modifier-chord keybindings and the playlist management overhaul.*
|
||||
*[@kveld9](https://github.com/kveld9) for the playlist management overhaul.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Privacy Policy
|
||||
|
||||
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
|
||||
|
||||
## Data sent to third-party services
|
||||
|
||||
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
|
||||
|
||||
### Your Subsonic / Navidrome server
|
||||
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
|
||||
|
||||
### Last.fm
|
||||
If you connect a Last.fm account in Settings, Psysonic sends:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album)
|
||||
- **Love / Unlove** — when you mark a track as loved or unloved
|
||||
|
||||
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
|
||||
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
|
||||
### NetEase Cloud Music (Lyrics)
|
||||
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
|
||||
|
||||
### Apple Music / iTunes Search API
|
||||
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
|
||||
|
||||
### Discord Rich Presence
|
||||
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
|
||||
|
||||
## Data stored locally
|
||||
|
||||
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
|
||||
|
||||
- Server profiles (URL, username, password)
|
||||
- Last.fm session key
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
## No telemetry
|
||||
|
||||
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
|
||||
|
||||
## Open source
|
||||
|
||||
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
|
||||
@@ -36,74 +36,25 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 67 beautiful themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations. A time-based **Theme Scheduler** can automatically switch between a day and night theme.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, Chinese, Norwegian, and Russian.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: Lyrics pane in the sidebar and fullscreen player — powered by LRCLIB and your Navidrome server. Synced lyrics auto-scroll with line highlighting and click-to-seek; plain-text fallback for unsynced tracks.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 🎛️ **10-Band Graphic EQ**: Built-in EQ with presets and the ability to save custom presets. **AutoEQ** support lets you load headphone correction profiles automatically.
|
||||
- 🔀 **Gapless & Crossfade**: True gapless playback and configurable crossfade between tracks (mutually exclusive).
|
||||
- 📻 **Internet Radio**: Built-in internet radio player — browse and play any ICY/HLS stream directly within Psysonic.
|
||||
- ⭐ **Ratings**: Rate songs, albums, and artists with 1–5 stars via the context menu, player bar, or album detail view. Supports the OpenSubsonic ratings extension. Auto-rate-down songs you skip repeatedly (configurable threshold). Filter Random Mix and Random Albums by minimum star rating.
|
||||
- 🖥️ **Fullscreen Player**: A dedicated fullscreen view with album art, animated lyrics overlay, and artist image — toggled with a single click.
|
||||
- 📋 **Playlist Management**: Create, edit, rename, and delete playlists. Drag-and-drop track reordering, song search, and smart suggestions right inside the playlist view.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays, multi-select album actions, and detailed artist pages with related albums.
|
||||
- 〰️ **Multi-Style Seekbar**: 10 canvas-drawn seekbar styles — Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker & UI Scale**: 10 UI fonts and a global zoom slider (80–150%) to match your display and taste.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔔 **System Tray**: Minimize Psysonic to the system tray. Play/Pause, Prev, Next, and Show/Hide controls available from the tray icon.
|
||||
- 💾 **Backup & Restore**: Export and import all your settings, themes, and server profiles in one click.
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
|
||||
- ⚡ **Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
|
||||
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
|
||||
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
|
||||
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
|
||||
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
|
||||
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
|
||||
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (1–5 stars), and multi-select actions.
|
||||
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
|
||||
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
|
||||
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
|
||||
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
|
||||
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Native Rust/rodio audio engine (replaces Howler.js)
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] AutoEQ — automatic headphone correction profile loader
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback
|
||||
- [x] Multi-style seekbar (10 styles: Waveform, Bar, Thick, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, Retro Tape)
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts, recent scrobbles, top-rated songs & artists
|
||||
- [x] Synchronized lyrics via LRCLIB and Navidrome server (in-sidebar + fullscreen, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] Internet Radio (ICY/HLS streams)
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
- [x] Advanced Search (text + genre + year + result-type filters)
|
||||
- [x] 67 themes across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Time-based Theme Scheduler (auto day/night theme switching)
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese, Norwegian, Russian)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts) + global UI scale slider
|
||||
- [x] Playlist management (create, edit, delete, drag-and-drop reorder, suggestions)
|
||||
- [x] Fullscreen player with synced lyrics overlay
|
||||
- [x] System tray icon with playback controls
|
||||
- [x] Song / Album / Artist ratings (1–5 stars, OpenSubsonic extension)
|
||||
- [x] Auto-rate-down on repeated skips + minimum-rating filter for mixes
|
||||
- [x] Album multi-select actions
|
||||
- [x] Custom Linux titlebar
|
||||
- [x] Backup & Restore (settings export/import)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 67 themes
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
|
||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||
- [ ] More languages
|
||||
|
||||
@@ -169,6 +120,10 @@ If you want to build Psysonic from source or contribute to the project:
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v18+)
|
||||
- [Rust](https://www.rust-lang.org/) (v1.75+)
|
||||
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
|
||||
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
|
||||
- macOS: `brew install cmake`
|
||||
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
|
||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
|
||||
|
||||
### Setup
|
||||
@@ -203,3 +158,7 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
|
||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Shell completion for `psysonic`
|
||||
|
||||
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
|
||||
|
||||
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
|
||||
|
||||
## zsh
|
||||
|
||||
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.zsh/completions
|
||||
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
|
||||
fpath=(~/.zsh/completions $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
```
|
||||
|
||||
If you use a plugin manager, point its `fpath` at this repo’s `completions/` directory instead.
|
||||
|
||||
## bash
|
||||
|
||||
Source the script once (e.g. in `~/.bashrc`):
|
||||
|
||||
```sh
|
||||
source /path/to/psysonic/completions/psysonic.bash
|
||||
```
|
||||
|
||||
Use this only under **bash** (including macOS’s `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
|
||||
|
||||
## Device names after `audio-device set`
|
||||
|
||||
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
|
||||
|
||||
## Folder ids after `library set`
|
||||
|
||||
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
|
||||
@@ -0,0 +1,144 @@
|
||||
#compdef psysonic
|
||||
# Zsh completion for Psysonic CLI (see `psysonic --help`).
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_audio_device_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_audio_device_json)" || return
|
||||
local -a devs
|
||||
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
|
||||
(( $#devs )) && compadd -a devs
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_folder_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_library_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_server_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_snapshot_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_globals() {
|
||||
compadd -J options -X 'option' -- \
|
||||
--help --version --info --json --quiet --player completions
|
||||
}
|
||||
|
||||
integer i pidx=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --player ]] && pidx=i
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
|
||||
compadd help bash zsh
|
||||
return
|
||||
fi
|
||||
_psysonic_globals
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub
|
||||
if (( pidx + 1 <= CURRENT - 1 )); then
|
||||
sub=( ${words[pidx + 1,CURRENT - 1]} )
|
||||
else
|
||||
sub=()
|
||||
fi
|
||||
|
||||
integer n=${#sub[@]}
|
||||
if (( n == 0 )); then
|
||||
compadd -J verbs -X 'player command' -- \
|
||||
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
|
||||
audio-device library server search mix
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[1]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd default
|
||||
_psysonic_audio_device_ids
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd all
|
||||
_psysonic_library_folder_ids
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && compadd append new
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
_psysonic_server_ids
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
compadd track album artist
|
||||
elif (( n >= 2 )); then
|
||||
_message -e descriptions 'search query'
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && compadd off all one
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && compadd 0 1 2 3 4 5
|
||||
;;
|
||||
seek)
|
||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||
;;
|
||||
volume)
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,167 @@
|
||||
# bash completion for Psysonic (see `psysonic --help`).
|
||||
# Install: source /path/to/completions/psysonic.bash
|
||||
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
|
||||
#
|
||||
# Uses no `mapfile` so bash 3.2 (macOS default) works.
|
||||
#
|
||||
# compopt is bash-only (programmable completion). Guard so sourcing this file
|
||||
# under zsh or plain sh does not print "command not found: compopt".
|
||||
|
||||
_psysonic_compopt() {
|
||||
command -v compopt &>/dev/null || return 0
|
||||
compopt "$@" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
_psysonic_compreply_from_compgen() {
|
||||
# $1 = compgen -W word list, $2 = current word
|
||||
COMPREPLY=()
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W "$1" -- "$2")
|
||||
}
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_complete() {
|
||||
local cur
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
local i pidx=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
|
||||
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
|
||||
return
|
||||
fi
|
||||
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub=()
|
||||
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
|
||||
sub+=("${COMP_WORDS[i]}")
|
||||
done
|
||||
local n=${#sub[@]}
|
||||
|
||||
if (( n == 0 )); then
|
||||
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[0]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf d
|
||||
jf="$(_psysonic_audio_device_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r d; do
|
||||
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
|
||||
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'default' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf id line
|
||||
jf="$(_psysonic_library_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r id; do
|
||||
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
|
||||
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'all' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf sid
|
||||
jf="$(_psysonic_snapshot_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r sid; do
|
||||
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
|
||||
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'track album artist' "$cur"
|
||||
elif (( n >= 2 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek|volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
play)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _psysonic_complete psysonic
|
||||
Generated
+43
-59
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.9",
|
||||
"version": "1.34.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.9",
|
||||
"version": "1.34.11",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
@@ -32,6 +34,7 @@
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
@@ -42,6 +45,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
@@ -801,6 +805,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
|
||||
"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",
|
||||
@@ -810,6 +823,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"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",
|
||||
@@ -1031,9 +1053,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1048,9 +1067,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1065,9 +1081,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1082,9 +1095,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1099,9 +1109,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1116,9 +1123,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1133,9 +1137,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1150,9 +1151,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1167,9 +1165,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1184,9 +1179,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1201,9 +1193,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1218,9 +1207,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1235,9 +1221,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1461,9 +1444,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1481,9 +1461,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1501,9 +1478,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1521,9 +1495,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1541,9 +1512,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1763,6 +1731,16 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -2278,9 +2256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2639,6 +2617,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.10",
|
||||
"version": "1.34.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,7 +14,9 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
@@ -36,6 +38,7 @@
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
@@ -46,6 +49,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.9
|
||||
pkgver=1.34.12
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -18,6 +18,7 @@ makedepends=(
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
'cmake'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
Generated
+195
-56
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
@@ -39,7 +39,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
@@ -327,7 +327,7 @@ version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
@@ -371,9 +371,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -475,7 +475,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -571,7 +571,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -619,6 +619,15 @@ dependencies = [
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.24.1"
|
||||
@@ -729,7 +738,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types 0.2.0",
|
||||
"foreign-types 0.5.0",
|
||||
@@ -753,7 +762,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
@@ -1051,7 +1060,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -1721,7 +1730,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1983,14 +1992,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.8"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@@ -2148,7 +2158,7 @@ version = "1.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"byteorder",
|
||||
"flate2",
|
||||
]
|
||||
@@ -2412,7 +2422,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -2753,7 +2763,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
@@ -2767,7 +2777,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
@@ -2907,7 +2917,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2920,7 +2930,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2931,7 +2941,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2959,7 +2969,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2972,7 +2982,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2983,7 +2993,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -2995,7 +3005,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -3007,7 +3017,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3065,12 +3075,27 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "opusic-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3280fe5b6f97ac1a35a0ac003e2fb0b92f8e4bdf2b2057e1bf9b87acca5696"
|
||||
dependencies = [
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3251,7 +3276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3261,7 +3286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3405,7 +3430,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -3558,7 +3583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.10"
|
||||
version = "1.34.12"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -3574,6 +3599,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -3588,13 +3615,14 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows 0.58.0",
|
||||
"zbus 5.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
@@ -3697,9 +3725,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -3803,7 +3831,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3890,6 +3918,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4036,7 +4065,7 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
@@ -4049,7 +4078,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
@@ -4070,6 +4099,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -4082,9 +4123,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.11"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4112,6 +4153,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -4124,7 +4174,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4169,6 +4219,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4193,7 +4266,7 @@ version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cssparser 0.36.0",
|
||||
"derive_more 2.1.1",
|
||||
"log",
|
||||
@@ -4687,6 +4760,17 @@ dependencies = [
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-adapter-libopus"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d17450685dda0e87467eddf3e0f9c0b2a1707fc5c3234c111f70d46c6e4494"
|
||||
dependencies = [
|
||||
"log",
|
||||
"opusic-sys",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
@@ -4875,6 +4959,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4894,7 +4989,7 @@ version = "0.34.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.25.0",
|
||||
@@ -5040,7 +5135,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5200,7 +5295,7 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5294,7 +5389,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5389,7 +5484,7 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -5455,9 +5550,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.1"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -5628,7 +5723,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -5853,9 +5948,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -6059,7 +6154,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
@@ -6143,9 +6238,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -6251,6 +6346,16 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -6293,6 +6398,18 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -6343,6 +6460,17 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
@@ -6365,6 +6493,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
@@ -6883,7 +7022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -7067,7 +7206,7 @@ dependencies = [
|
||||
"nix",
|
||||
"once_cell",
|
||||
"ordered-stream",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
@@ -7108,7 +7247,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.15",
|
||||
"zbus_macros 5.14.0",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.10"
|
||||
version = "1.34.12"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -32,7 +32,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
@@ -45,11 +45,16 @@ discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Psysonic is a music player only — it does not record audio.
|
||||
This description is shown if macOS prompts for microphone access
|
||||
(triggered by CoreAudio enumerating input devices during init). -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
|
||||
|
||||
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
|
||||
Navidrome servers running without HTTPS can load media in WKWebView.
|
||||
Without this, macOS App Transport Security blocks HTTP audio src. -->
|
||||
|
||||
+427
-57
@@ -1,5 +1,5 @@
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
#[cfg(unix)]
|
||||
@@ -13,7 +13,7 @@ use rodio::source::UniformSourceIterator;
|
||||
use serde::Serialize;
|
||||
use symphonia::core::{
|
||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||
codecs::{CodecRegistry, DecoderOptions, CODEC_TYPE_NULL},
|
||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||||
meta::MetadataOptions,
|
||||
@@ -443,6 +443,14 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||
/// Small enough that stale audio drains within a few seconds on reconnect;
|
||||
/// large enough to absorb brief network hiccups without stuttering.
|
||||
const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||
/// Minimum ring buffer for on-demand track streaming starts.
|
||||
const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
|
||||
/// Cap ring buffer growth when content-length is known.
|
||||
const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
|
||||
/// Max bytes kept in memory to promote a completed streamed track for fast replay/seek recovery.
|
||||
const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Consecutive body-stream failures tolerated for track streaming before abort.
|
||||
const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
|
||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||
const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
|
||||
@@ -580,6 +588,11 @@ struct AudioStreamReader {
|
||||
deadline: std::time::Instant,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
/// Diagnostic tag for logs ("radio" or "track-stream").
|
||||
source_tag: &'static str,
|
||||
/// Optional completion marker: when true and the ring buffer is empty,
|
||||
/// return EOF immediately (used by one-shot track streaming).
|
||||
eof_when_empty: Option<Arc<AtomicBool>>,
|
||||
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
||||
pos: u64,
|
||||
}
|
||||
@@ -615,14 +628,22 @@ impl Read for AudioStreamReader {
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
return Ok(read);
|
||||
}
|
||||
if self
|
||||
.eof_when_empty
|
||||
.as_ref()
|
||||
.is_some_and(|done| done.load(Ordering::SeqCst))
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
if std::time::Instant::now() >= self.deadline {
|
||||
eprintln!(
|
||||
"[radio] AudioStreamReader: {}s without data → EOF",
|
||||
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||
self.source_tag,
|
||||
RADIO_READ_TIMEOUT_SECS
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"radio: no data received",
|
||||
format!("{}: no data received", self.source_tag),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||
@@ -636,7 +657,7 @@ impl Seek for AudioStreamReader {
|
||||
SeekFrom::Current(0) => Ok(self.pos),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"radio stream is not seekable",
|
||||
format!("{} stream is not seekable", self.source_tag),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -682,11 +703,22 @@ impl Drop for RadioLiveState {
|
||||
// [features]
|
||||
// fdk-aac = ["dep:symphonia-adapter-fdk-aac"]
|
||||
|
||||
/// Symphonia’s default codec set for our enabled features, plus Opus via libopus.
|
||||
fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
fn try_make_radio_decoder(
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
opts: &DecoderOptions,
|
||||
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
|
||||
symphonia::default::get_codecs().make(params, opts)
|
||||
psysonic_codec_registry().make(params, opts)
|
||||
}
|
||||
|
||||
// ── Async HTTP Download Task ──────────────────────────────────────────────────
|
||||
@@ -842,6 +874,132 @@ async fn radio_download_task(
|
||||
eprintln!("[radio] download task done ({bytes_total} B total)");
|
||||
}
|
||||
|
||||
/// One-shot HTTP downloader for track streaming starts.
|
||||
///
|
||||
/// Pushes response chunks into an SPSC ring buffer consumed by `AudioStreamReader`.
|
||||
/// Terminates when:
|
||||
/// - generation changes (track superseded),
|
||||
/// - response stream ends, or
|
||||
/// - response emits an error.
|
||||
async fn track_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
mut prod: HeapProducer<u8>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let mut capture: Vec<u8> = Vec::new();
|
||||
let mut capture_over_limit = false;
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
r
|
||||
} else {
|
||||
let mut req = http_client.get(&url);
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
eprintln!(
|
||||
"[audio] streaming reconnect failed after {} attempts: {}",
|
||||
reconnects, err
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
};
|
||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
eprintln!(
|
||||
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
||||
response.status()
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if downloaded == 0 && !response.status().is_success() {
|
||||
eprintln!("[audio] streaming HTTP {}", response.status());
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
eprintln!(
|
||||
"[audio] streaming download error after {} reconnects: {}",
|
||||
reconnects, e
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
eprintln!(
|
||||
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
||||
reconnects,
|
||||
TRACK_STREAM_MAX_RECONNECTS,
|
||||
e
|
||||
);
|
||||
next_response = None;
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
reconnects = 0;
|
||||
let mut offset = 0;
|
||||
while offset < chunk.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let pushed = prod.push_slice(&chunk[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
if !capture_over_limit {
|
||||
if capture.len().saturating_add(pushed) <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
let from = offset;
|
||||
let to = offset + pushed;
|
||||
capture.extend_from_slice(&chunk[from..to]);
|
||||
} else {
|
||||
capture.clear();
|
||||
capture_over_limit = true;
|
||||
}
|
||||
}
|
||||
offset += pushed;
|
||||
downloaded += pushed as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !capture_over_limit && !capture.is_empty() {
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: capture,
|
||||
});
|
||||
}
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type_to_hint(ct: &str) -> Option<String> {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
||||
@@ -980,7 +1138,7 @@ impl SizedDecoder {
|
||||
.zip(track.codec_params.n_frames)
|
||||
.map(|(base, frames)| base.calc_time(frames));
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
let mut decoder = psysonic_codec_registry()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| {
|
||||
eprintln!("[psysonic] codec init failed: {e}");
|
||||
@@ -1042,11 +1200,15 @@ impl SizedDecoder {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a decoder from any `MediaSource` (e.g. `RadioBuffer`).
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||
// Larger read-ahead buffer for the live radio SPSC consumer — reduces
|
||||
fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
) -> Result<Self, String> {
|
||||
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
|
||||
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||
let mut hint = Hint::new();
|
||||
@@ -1054,16 +1216,16 @@ impl SizedDecoder {
|
||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("radio: format probe failed: {e}"))?;
|
||||
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
|
||||
|
||||
let track = probed.format.tracks().iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "radio: no audio track found".to_string())?;
|
||||
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||||
let track_id = track.id;
|
||||
// Live streams have no known total frame count → total_duration = None.
|
||||
let total_duration = None;
|
||||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("radio: codec init failed: {e}"))?;
|
||||
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
|
||||
let mut format = probed.format;
|
||||
|
||||
let mut errors = 0usize;
|
||||
@@ -1077,12 +1239,12 @@ impl SizedDecoder {
|
||||
Ok(d) => break d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
errors += 1;
|
||||
eprintln!("[psysonic] radio init: dropped corrupt frame #{errors}: {msg}");
|
||||
eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}");
|
||||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("radio: too many consecutive decode errors".into());
|
||||
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("radio: decode error: {e}")),
|
||||
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||||
}
|
||||
};
|
||||
let spec = decoded.spec().to_owned();
|
||||
@@ -1418,11 +1580,70 @@ fn build_source(
|
||||
})
|
||||
}
|
||||
|
||||
/// Streaming variant of `build_source`: uses a live `SizedDecoder` source
|
||||
/// (non-seekable) and skips iTunSMPB parsing, but preserves the same EQ/fade/
|
||||
/// counting wrappers and output metadata.
|
||||
fn build_streaming_source(
|
||||
decoder: SizedDecoder,
|
||||
duration_hint: f64,
|
||||
eq_gains: Arc<[AtomicU32; 10]>,
|
||||
eq_enabled: Arc<AtomicBool>,
|
||||
eq_pre_gain: Arc<AtomicU32>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
// For streaming starts prefer server-provided duration when available.
|
||||
let effective_dur = if duration_hint > 1.0 {
|
||||
duration_hint
|
||||
} else {
|
||||
decoder
|
||||
.total_duration()
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(duration_hint)
|
||||
};
|
||||
|
||||
let converted = decoder.convert_samples::<f32>();
|
||||
let dyn_src: DynSource = if target_rate > 0 && sample_rate != target_rate {
|
||||
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
|
||||
} else {
|
||||
DynSource::new(converted)
|
||||
};
|
||||
|
||||
let output_rate = if target_rate > 0 && sample_rate != target_rate {
|
||||
target_rate
|
||||
} else {
|
||||
sample_rate
|
||||
};
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = CountingSource::new(notifying, sample_counter);
|
||||
|
||||
Ok(BuiltSource {
|
||||
source: counting,
|
||||
duration_secs: effective_dur,
|
||||
output_rate,
|
||||
output_channels: channels,
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Engine state ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) struct PreloadedTrack {
|
||||
url: String,
|
||||
data: Vec<u8>,
|
||||
pub(crate) url: String,
|
||||
pub(crate) data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Info about the track that has been appended (chained) to the current Sink
|
||||
@@ -1462,7 +1683,10 @@ pub struct AudioEngine {
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
pub eq_pre_gain: Arc<AtomicU32>,
|
||||
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
/// Last fully downloaded manual-stream track bytes (same playback identity),
|
||||
/// used to recover seek/replay without waiting for network again.
|
||||
pub(crate) stream_completed_cache: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||
@@ -1471,7 +1695,7 @@ pub struct AudioEngine {
|
||||
pub gapless_enabled: Arc<AtomicBool>,
|
||||
/// Info about the next-up chained track (gapless mode).
|
||||
/// The progress task reads this when `current_source_done` fires.
|
||||
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
/// Atomic sample counter — incremented by CountingSource in the audio thread.
|
||||
/// Progress task reads this for drift-free position tracking.
|
||||
pub samples_played: Arc<AtomicU64>,
|
||||
@@ -1484,7 +1708,7 @@ pub struct AudioEngine {
|
||||
pub gapless_switch_at: Arc<AtomicU64>,
|
||||
/// Active radio session state. None for regular (non-radio) tracks.
|
||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||
pub radio_state: Mutex<Option<RadioLiveState>>,
|
||||
pub(crate) radio_state: Mutex<Option<RadioLiveState>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -1720,6 +1944,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
@@ -1771,6 +1996,18 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take (consume) completed manual-stream bytes if they correspond to `url`.
|
||||
pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
|
||||
let mut guard = state.stream_completed_cache.lock().unwrap();
|
||||
if guard
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, url))
|
||||
{
|
||||
return guard.take().map(|p| p.data);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetch track bytes from the preload cache or via HTTP.
|
||||
async fn fetch_data(
|
||||
url: &str,
|
||||
@@ -1778,7 +2015,20 @@ async fn fetch_data(
|
||||
gen: u64,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
// Check preload cache first.
|
||||
// Check completed streamed-track cache first (manual streaming fallback cache).
|
||||
let streamed_cached = {
|
||||
let mut streamed = state.stream_completed_cache.lock().unwrap();
|
||||
if streamed.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||
streamed.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(data) = streamed_cached {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
// Check preload cache next.
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||
@@ -1947,15 +2197,104 @@ pub async fn audio_play(
|
||||
old.stop();
|
||||
}
|
||||
|
||||
// Fetch bytes (preload cache) unless we reused the chained download above.
|
||||
let data = if let Some(d) = reuse_chained_bytes {
|
||||
Some(d)
|
||||
// Extract format hint from URL for better symphonia probing.
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
|
||||
enum PlayInput {
|
||||
Bytes(Vec<u8>),
|
||||
Streaming {
|
||||
reader: AudioStreamReader,
|
||||
format_hint: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// Data source selection:
|
||||
// 1) Reused chained bytes (manual skip onto pre-chained track)
|
||||
// 2) Manual uncached remote start -> stream immediately (no full prefetch)
|
||||
// 3) Existing byte path (preloaded/offline/full HTTP fetch)
|
||||
let play_input = if let Some(d) = reuse_chained_bytes {
|
||||
PlayInput::Bytes(d)
|
||||
} else {
|
||||
fetch_data(&url, &state, gen, &app).await?
|
||||
};
|
||||
let data = match data {
|
||||
Some(d) => d,
|
||||
None => return Ok(()), // superseded while downloading
|
||||
let stream_cache_hit = {
|
||||
let streamed = state.stream_completed_cache.lock().unwrap();
|
||||
streamed
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, &url))
|
||||
};
|
||||
let preloaded_hit = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, &url))
|
||||
};
|
||||
let is_local = url.starts_with("psysonic-local://");
|
||||
|
||||
if manual && !stream_cache_hit && !preloaded_hit && !is_local {
|
||||
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // superseded
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
).or_else(|| format_hint.clone());
|
||||
|
||||
let buffer_cap = response
|
||||
.content_length()
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
|
||||
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
|
||||
let rb = HeapRb::<u8>::new(buffer_cap);
|
||||
let (prod, cons) = rb.split();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
tokio::spawn(track_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.http_client.clone(),
|
||||
url.clone(),
|
||||
response,
|
||||
prod,
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
));
|
||||
|
||||
// Track streaming has no reconnect producer; keep an empty channel.
|
||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
||||
let reader = AudioStreamReader {
|
||||
cons,
|
||||
new_cons_rx: Mutex::new(new_cons_rx),
|
||||
deadline: std::time::Instant::now()
|
||||
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen,
|
||||
source_tag: "track-stream",
|
||||
eof_when_empty: Some(done),
|
||||
pos: 0,
|
||||
};
|
||||
PlayInput::Streaming {
|
||||
reader,
|
||||
format_hint: stream_hint,
|
||||
}
|
||||
} else {
|
||||
let data = fetch_data(&url, &state, gen, &app).await?;
|
||||
let data = match data {
|
||||
Some(d) => d,
|
||||
None => return Ok(()), // superseded while downloading
|
||||
};
|
||||
PlayInput::Bytes(data)
|
||||
}
|
||||
};
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
@@ -1998,23 +2337,40 @@ pub async fn audio_play(
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
// Extract format hint from URL for better symphonia probing.
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let built = build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::Streaming { reader, format_hint } => {
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref(), "track-stream")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
)
|
||||
}
|
||||
}.map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
let output_rate = built.output_rate;
|
||||
@@ -2381,8 +2737,9 @@ fn spawn_progress_task(
|
||||
let mut samples_played = samples_played;
|
||||
|
||||
loop {
|
||||
// 500 ms tick — frontend interpolates visually at 60 fps via rAF.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
// 100 ms tick keeps near-end detection timely for crossfade/gapless
|
||||
// handoff while frontend still interpolates smoothly via rAF.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
@@ -2585,6 +2942,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
*state.stream_completed_cache.lock().unwrap() = None;
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
@@ -2722,11 +3080,13 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
let _ = app.emit("audio:preload-ready", url.clone());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -2748,7 +3108,9 @@ pub async fn audio_preload(
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
};
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
let url_for_emit = url.clone();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
let _ = app.emit("audio:preload-ready", url_for_emit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2838,6 +3200,8 @@ pub async fn audio_play_radio(
|
||||
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen,
|
||||
source_tag: "radio",
|
||||
eof_when_empty: None,
|
||||
pos: 0,
|
||||
};
|
||||
|
||||
@@ -2845,7 +3209,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref())
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -3015,6 +3379,17 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Returns the names of all available audio output devices on the current host.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
@@ -3023,13 +3398,7 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *state.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
audio_list_devices_for_engine(&state)
|
||||
}
|
||||
|
||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||
@@ -3093,6 +3462,7 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
// Linux: case 2 is disabled — ALSA/cpal often omit the active sink from
|
||||
// enumeration while streaming, which caused false resets to system default.
|
||||
|
||||
|
||||
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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+48
-37
@@ -216,6 +216,16 @@ fn try_connect() -> Option<DiscordIpcClient> {
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Apply a template string, replacing placeholders with actual values.
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>) -> String {
|
||||
let album_text = album.unwrap_or("");
|
||||
template
|
||||
.replace("{title}", title)
|
||||
.replace("{artist}", artist)
|
||||
.replace("{album}", album_text)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
|
||||
@@ -225,6 +235,12 @@ fn try_connect() -> Option<DiscordIpcClient> {
|
||||
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
|
||||
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
|
||||
/// without making any external request — required for privacy opt-in.
|
||||
/// - `details_template`: template string for the "details" field. Default: "{artist} - {title}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `state_template`: template string for the "state" field. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
#[tauri::command]
|
||||
pub async fn discord_update_presence(
|
||||
state: tauri::State<'_, DiscordState>,
|
||||
@@ -235,6 +251,9 @@ pub async fn discord_update_presence(
|
||||
elapsed_secs: Option<f64>,
|
||||
cover_art_url: Option<String>,
|
||||
fetch_itunes_covers: bool,
|
||||
details_template: Option<String>,
|
||||
state_template: Option<String>,
|
||||
large_text_template: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
|
||||
// run on the Tokio async executor directly.
|
||||
@@ -273,62 +292,54 @@ pub async fn discord_update_presence(
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
// Discord RPC only exposes two visible text rows (details + state).
|
||||
// The application name "Psysonic" is shown automatically by Discord as the
|
||||
// header line. Album goes into large_text — visible as a hover tooltip on
|
||||
// the cover art icon.
|
||||
let large_text = album.as_deref().unwrap_or("Psysonic");
|
||||
// Apply templates for the three configurable text fields.
|
||||
let details_str = details_template.as_deref().unwrap_or("{artist} - {title}");
|
||||
let details_text = apply_template(details_str, &title, &artist, album.as_deref());
|
||||
|
||||
let state_str = state_template.as_deref().unwrap_or("{album}");
|
||||
let state_text = apply_template(state_str, &title, &artist, album.as_deref());
|
||||
|
||||
let large_text_str = large_text_template.as_deref().unwrap_or("{album}");
|
||||
let large_text = apply_template(large_text_str, &title, &artist, album.as_deref());
|
||||
|
||||
let assets = if let Some(ref url) = artwork_url {
|
||||
Assets::new()
|
||||
.large_image(url.as_str())
|
||||
.large_text(large_text)
|
||||
.large_text(&large_text)
|
||||
} else {
|
||||
// Fallback to default Psysonic icon
|
||||
Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text)
|
||||
.large_text(&large_text)
|
||||
};
|
||||
|
||||
// When paused, show "Paused" as the state text (replaces artist name).
|
||||
let state_text: String = if is_playing {
|
||||
artist.clone()
|
||||
} else {
|
||||
"Paused".to_string()
|
||||
};
|
||||
// When paused: clear activity completely to avoid any timer issues
|
||||
// When playing: show full activity with timer
|
||||
if !is_playing {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ActivityType::Listening causes the Discord client to auto-start a running
|
||||
// timer from "now" even when no timestamps are provided. Switch to Playing
|
||||
// when paused — Playing only shows a timer when timestamps are explicitly set.
|
||||
let activity_type = if is_playing {
|
||||
ActivityType::Listening
|
||||
} else {
|
||||
ActivityType::Playing
|
||||
};
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.activity_type(activity_type)
|
||||
.details(&title)
|
||||
// Only reach here when playing
|
||||
let activity = Activity::new()
|
||||
.activity_type(ActivityType::Listening)
|
||||
.details(&details_text)
|
||||
.state(&state_text)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
// Only set when playing — Playing type without timestamps shows no timer.
|
||||
if is_playing {
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
.assets(assets)
|
||||
.timestamps(if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
}
|
||||
Timestamps::new().start(start)
|
||||
} else {
|
||||
Timestamps::new()
|
||||
});
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
|
||||
// call re-connects.
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
|
||||
+907
-11
File diff suppressed because it is too large
Load Diff
@@ -13,5 +13,24 @@ fn main() {
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
}
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if psysonic_lib::cli::wants_version(&args) {
|
||||
psysonic_lib::cli::print_version();
|
||||
return;
|
||||
}
|
||||
if psysonic_lib::cli::wants_help(&args) {
|
||||
psysonic_lib::cli::print_help(
|
||||
args.first().map(|s| s.as_str()).unwrap_or("psysonic"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(code) = psysonic_lib::cli::try_completions_dispatch(&args) {
|
||||
std::process::exit(code);
|
||||
}
|
||||
if psysonic_lib::cli::wants_info(&args) {
|
||||
psysonic_lib::cli::run_info_and_exit(&args);
|
||||
}
|
||||
|
||||
psysonic_lib::run();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.10",
|
||||
"version": "1.34.12",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+339
-2
@@ -36,6 +36,7 @@ import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import DeviceSync from './pages/DeviceSync';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
@@ -57,10 +58,22 @@ import { IS_LINUX } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import {
|
||||
getMusicFolders,
|
||||
getSimilarSongs,
|
||||
getSong,
|
||||
probeEntityRatingSupport,
|
||||
search as subsonicSearch,
|
||||
setRating,
|
||||
star,
|
||||
unstar,
|
||||
} from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import i18n from './i18n';
|
||||
import { playByOpaqueId } from './utils/playByOpaqueId';
|
||||
import { switchActiveServer } from './utils/switchActiveServer';
|
||||
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
@@ -70,6 +83,9 @@ import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
|
||||
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
|
||||
let cliPremuteVolume: number | null = null;
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||
@@ -370,6 +386,7 @@ function AppShell() {
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -451,6 +468,261 @@ function TauriEventBridge() {
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// CLI: `--player audio-device set …` (forwarded on Linux via single-instance).
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<string>('cli:audio-device-set', async e => {
|
||||
const raw = typeof e.payload === 'string' ? e.payload : '';
|
||||
const deviceName = raw.length > 0 ? raw : null;
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName });
|
||||
useAuthStore.getState().setAudioOutputDevice(deviceName);
|
||||
} catch {
|
||||
/* device open failed — do not persist (same as Settings) */
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// CLI: `--player mix append|new` from the currently playing track.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<string>('cli:instant-mix', async e => {
|
||||
const mode = e.payload === 'append' ? 'append' : 'new';
|
||||
const state = usePlayerStore.getState();
|
||||
const song = state.currentTrack;
|
||||
if (!song) {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
try {
|
||||
const similar = await getSimilarSongs(song.id, 50);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
||||
const base = similar.filter(s => s.id !== song.id).map(s => songToTrack(s));
|
||||
if (mode === 'append') {
|
||||
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
|
||||
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
|
||||
} else {
|
||||
// New queue from seed: collapse to [song] first, then radio tail (not append onto old queue).
|
||||
usePlayerStore.getState().reseedQueueForInstantMix(song);
|
||||
const shuffled = shuffleArray(
|
||||
base.map(t => ({ ...t, radioAdded: true as const })),
|
||||
);
|
||||
if (shuffled.length > 0) {
|
||||
const aid = song.artistId?.trim() || undefined;
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('CLI instant mix failed', err);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
|
||||
showToast(i18n.t('contextMenu.instantMixFailed'), 5000, 'error');
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// CLI: `--player library list` (Rust polls the JSON file) / `library set`.
|
||||
useEffect(() => {
|
||||
let u1: (() => void) | undefined;
|
||||
let u2: (() => void) | undefined;
|
||||
listen('cli:library-list', async () => {
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
const auth = useAuthStore.getState();
|
||||
const sid = auth.activeServerId;
|
||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
||||
await invoke('cli_publish_library_list', {
|
||||
payload: {
|
||||
folders: folders.map(f => ({ id: f.id, name: f.name })),
|
||||
selected,
|
||||
active_server_id: sid,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('CLI library list failed', e);
|
||||
await invoke('cli_publish_library_list', {
|
||||
payload: { folders: [], selected: 'all', active_server_id: null },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).then(u => { u1 = u; });
|
||||
listen<string>('cli:library-set', e => {
|
||||
const raw = typeof e.payload === 'string' ? e.payload : '';
|
||||
if (raw === 'all') useAuthStore.getState().setMusicLibraryFilter('all');
|
||||
else if (raw.length > 0) useAuthStore.getState().setMusicLibraryFilter(raw);
|
||||
}).then(u => { u2 = u; });
|
||||
return () => {
|
||||
u1?.();
|
||||
u2?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CLI: servers, search, transport extras, mute, star, rating, play-by-id, reload.
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
listen('cli:server-list', async () => {
|
||||
const auth = useAuthStore.getState();
|
||||
await invoke('cli_publish_server_list', {
|
||||
payload: {
|
||||
active_server_id: auth.activeServerId,
|
||||
servers: auth.servers.map(s => ({ id: s.id, name: s.name })),
|
||||
},
|
||||
});
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<string>('cli:server-set', async e => {
|
||||
const raw = typeof e.payload === 'string' ? e.payload : '';
|
||||
const id = raw.trim();
|
||||
if (!id) return;
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (!server) {
|
||||
showToast(i18n.t('contextMenu.cliServerNotFound', { defaultValue: 'Server id not found.' }), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
const ok = await switchActiveServer(server);
|
||||
if (!ok) {
|
||||
showToast(i18n.t('contextMenu.cliServerSwitchFailed', { defaultValue: 'Could not switch server (ping failed).' }), 5000, 'error');
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<{ scope: string; query: string }>('cli:search', async e => {
|
||||
const { scope, query } = e.payload;
|
||||
const base = { scope, query, ready: false };
|
||||
try {
|
||||
const r = await subsonicSearch(query, { songCount: 50, albumCount: 30, artistCount: 30 });
|
||||
const payload =
|
||||
scope === 'track'
|
||||
? {
|
||||
...base,
|
||||
songs: r.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist })),
|
||||
albums: [] as { id: string; name: string; artist: string }[],
|
||||
artists: [] as { id: string; name: string }[],
|
||||
ready: true,
|
||||
}
|
||||
: scope === 'album'
|
||||
? {
|
||||
...base,
|
||||
songs: [] as { id: string; title: string; artist: string }[],
|
||||
albums: r.albums.map(a => ({ id: a.id, name: a.name, artist: a.artist })),
|
||||
artists: [] as { id: string; name: string }[],
|
||||
ready: true,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
songs: [] as { id: string; title: string; artist: string }[],
|
||||
albums: [] as { id: string; name: string; artist: string }[],
|
||||
artists: r.artists.map(a => ({ id: a.id, name: a.name })),
|
||||
ready: true,
|
||||
};
|
||||
await invoke('cli_publish_search_results', { payload });
|
||||
} catch (err) {
|
||||
console.error('CLI search failed', err);
|
||||
await invoke('cli_publish_search_results', {
|
||||
payload: {
|
||||
...base,
|
||||
songs: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
ready: true,
|
||||
error: err instanceof Error ? err.message : 'search failed',
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<string>('cli:play-id', async e => {
|
||||
const id = typeof e.payload === 'string' ? e.payload.trim() : '';
|
||||
if (!id) return;
|
||||
try {
|
||||
await playByOpaqueId(id);
|
||||
} catch (err) {
|
||||
console.error('CLI play failed', err);
|
||||
const notFound = err instanceof Error && err.message === 'play_by_id_not_found';
|
||||
showToast(
|
||||
i18n.t('contextMenu.cliPlayIdNotFound', {
|
||||
defaultValue: notFound
|
||||
? 'No song, album, or artist matches this id.'
|
||||
: 'Could not start playback.',
|
||||
}),
|
||||
5000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:shuffle-queue', () => {
|
||||
usePlayerStore.getState().shuffleQueue();
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<string>('cli:set-repeat', e => {
|
||||
const m = typeof e.payload === 'string' ? e.payload : '';
|
||||
const mode = m === 'all' ? 'all' : m === 'one' ? 'one' : 'off';
|
||||
usePlayerStore.setState({ repeatMode: mode });
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:mute', () => {
|
||||
const { volume, setVolume } = usePlayerStore.getState();
|
||||
if (volume > 0) cliPremuteVolume = volume;
|
||||
setVolume(0);
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:unmute', () => {
|
||||
const restore = cliPremuteVolume ?? 0.8;
|
||||
cliPremuteVolume = null;
|
||||
usePlayerStore.getState().setVolume(restore);
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<boolean>('cli:star-current', async e => {
|
||||
const want = e.payload === true;
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
if (!track) {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (want) {
|
||||
await star(track.id, 'song');
|
||||
usePlayerStore.getState().setStarredOverride(track.id, true);
|
||||
} else {
|
||||
await unstar(track.id, 'song');
|
||||
usePlayerStore.getState().setStarredOverride(track.id, false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('CLI star failed', err);
|
||||
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<number>('cli:set-rating-current', async e => {
|
||||
const stars = e.payload;
|
||||
if (typeof stars !== 'number' || Number.isNaN(stars) || stars < 0 || stars > 5) return;
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
if (!track) {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setRating(track.id, stars);
|
||||
usePlayerStore.getState().setUserRatingOverride(track.id, stars);
|
||||
} catch (err) {
|
||||
console.error('CLI set rating failed', err);
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:reload-player', async () => {
|
||||
const store = usePlayerStore.getState();
|
||||
const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
|
||||
stop();
|
||||
resetAudioPause();
|
||||
await invoke('audio_stop').catch(() => {});
|
||||
if (currentTrack) {
|
||||
try {
|
||||
const fresh = await getSong(currentTrack.id);
|
||||
const t = fresh ? songToTrack(fresh) : currentTrack;
|
||||
playTrack(t, queue, true);
|
||||
} catch {
|
||||
playTrack(currentTrack, queue, true);
|
||||
}
|
||||
} else {
|
||||
await initializeFromServerQueue();
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
return () => {
|
||||
unsubs.forEach(u => u());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -522,11 +794,14 @@ function TauriEventBridge() {
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }],
|
||||
['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:stop', () => usePlayerStore.getState().stop()],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
@@ -557,6 +832,15 @@ function TauriEventBridge() {
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
{
|
||||
const u = await listen<number>('media:set-volume', e => {
|
||||
const p = e.payload;
|
||||
if (typeof p !== 'number' || Number.isNaN(p)) return;
|
||||
usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100)));
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
@@ -575,6 +859,59 @@ function TauriEventBridge() {
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
|
||||
useEffect(() => {
|
||||
let tid: ReturnType<typeof setTimeout> | undefined;
|
||||
const publish = () => {
|
||||
const s = usePlayerStore.getState();
|
||||
const auth = useAuthStore.getState();
|
||||
const sid = auth.activeServerId;
|
||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
||||
const ct = s.currentTrack;
|
||||
const currentTrackUserRating =
|
||||
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
|
||||
const currentTrackStarred =
|
||||
ct != null
|
||||
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
|
||||
: null;
|
||||
const snapshot = {
|
||||
current_track: s.currentTrack,
|
||||
current_radio: s.currentRadio,
|
||||
queue: s.queue,
|
||||
queue_index: s.queueIndex,
|
||||
queue_length: s.queue.length,
|
||||
is_playing: s.isPlaying,
|
||||
current_time: s.currentTime,
|
||||
volume: s.volume,
|
||||
repeat_mode: s.repeatMode,
|
||||
current_track_user_rating: currentTrackUserRating,
|
||||
current_track_starred: currentTrackStarred,
|
||||
servers: auth.servers.map(({ id, name }) => ({ id, name })),
|
||||
music_library: {
|
||||
active_server_id: sid,
|
||||
selected,
|
||||
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
|
||||
},
|
||||
};
|
||||
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
|
||||
};
|
||||
publish();
|
||||
const schedule = () => {
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
tid = setTimeout(() => {
|
||||
tid = undefined;
|
||||
publish();
|
||||
}, 200);
|
||||
};
|
||||
const unsubP = usePlayerStore.subscribe(schedule);
|
||||
const unsubA = useAuthStore.subscribe(schedule);
|
||||
return () => {
|
||||
unsubP();
|
||||
unsubA();
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+13
-4
@@ -23,7 +23,7 @@ function getAuthParams(username: string, password: string) {
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
export function getClient() {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
@@ -110,6 +110,8 @@ export interface SubsonicSong {
|
||||
genre?: string;
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
/** ISRC code when available (e.g., Navidrome) */
|
||||
isrc?: string;
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
@@ -741,11 +743,18 @@ export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSam
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
const data = await api<{ artists: { index: any } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const indices = data.artists?.index ?? [];
|
||||
return indices.flatMap(i => i.artist ?? []);
|
||||
const rawIdx = data.artists?.index;
|
||||
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
||||
const artists: SubsonicArtist[] = [];
|
||||
for (const idx of indices) {
|
||||
const rawArt = idx.artist;
|
||||
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
||||
artists.push(...arr);
|
||||
}
|
||||
return artists;
|
||||
}
|
||||
|
||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
|
||||
@@ -85,6 +86,7 @@ interface AlbumHeaderProps {
|
||||
onRemoveOffline: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onShuffleAll?: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
entityRatingValue: number;
|
||||
@@ -111,6 +113,7 @@ export default function AlbumHeader({
|
||||
onRemoveOffline,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onShuffleAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
entityRatingValue,
|
||||
@@ -120,6 +123,7 @@ export default function AlbumHeader({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
@@ -138,14 +142,16 @@ export default function AlbumHeader({
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
<>
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
@@ -165,7 +171,6 @@ export default function AlbumHeader({
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
@@ -291,27 +296,33 @@ export default function AlbumHeader({
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={onShuffleAll}
|
||||
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
className="btn btn-ghost"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check, RotateCcw } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -10,6 +10,7 @@ import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -19,11 +20,11 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate}`);
|
||||
return parts.join(' ');
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
@@ -108,6 +109,7 @@ const TrackRow = React.memo(function TrackRow({
|
||||
}: TrackRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
// Fine-grained: only re-renders when THIS row's selection boolean flips.
|
||||
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
||||
const isActive = currentTrackId === song.id;
|
||||
@@ -192,8 +194,8 @@ const TrackRow = React.memo(function TrackRow({
|
||||
case 'format':
|
||||
return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
{(song.suffix || (showBitrate && song.bitRate)) && (
|
||||
<span className="track-codec">{codecLabel(song, showBitrate)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -288,7 +290,7 @@ export default function AlbumTrackList({
|
||||
// ── Column state ──────────────────────────────────────────────────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
startResize, toggleColumn, resetColumns,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
@@ -530,6 +532,46 @@ export default function AlbumTrackList({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Column visibility picker - outside .tracklist to avoid overflow cutoff */}
|
||||
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
|
||||
<div className="tracklist-col-picker">
|
||||
<button
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">
|
||||
{isOn && <Check size={13} />}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="tracklist-col-picker-divider" />
|
||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="tracklist"
|
||||
ref={tracklistRef}
|
||||
@@ -571,42 +613,10 @@ export default function AlbumTrackList({
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header-wrapper">
|
||||
<div className="tracklist-header" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
|
||||
{/* Column visibility picker */}
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">
|
||||
{isOn && <Check size={13} />}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tracks ── */}
|
||||
@@ -647,5 +657,6 @@ export default function AlbumTrackList({
|
||||
))}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore, type ServerProfile } from '../store/authStore';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -11,28 +16,141 @@ interface Props {
|
||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const multi = servers.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (hostRef.current?.contains(e.target as Node)) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const goServerSettings = () => {
|
||||
setMenuOpen(false);
|
||||
navigate('/settings', { state: { tab: 'server' } });
|
||||
};
|
||||
|
||||
const onTriggerClick = () => {
|
||||
if (!multi) {
|
||||
goServerSettings();
|
||||
return;
|
||||
}
|
||||
setMenuOpen(o => !o);
|
||||
};
|
||||
|
||||
const onPickServer = async (srv: ServerProfile) => {
|
||||
if (srv.id === activeServerId) {
|
||||
setMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
setSwitchingId(srv.id);
|
||||
const ok = await switchActiveServer(srv);
|
||||
setSwitchingId(null);
|
||||
setMenuOpen(false);
|
||||
if (!ok) {
|
||||
showToast(t('connection.switchFailed'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const tooltip =
|
||||
status === 'connected'
|
||||
const tooltip = multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server">{serverName}</span>
|
||||
<div className="connection-indicator-host" ref={hostRef}>
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onTriggerClick}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
role={multi ? 'button' : undefined}
|
||||
aria-haspopup={multi ? 'menu' : undefined}
|
||||
aria-expanded={multi ? menuOpen : undefined}
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
||||
{multi && (
|
||||
<ChevronDown size={12} className={menuOpen ? 'connection-indicator-chevron--open' : undefined} style={{ flexShrink: 0, opacity: 0.85 }} aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{multi && menuOpen && (
|
||||
<div
|
||||
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
|
||||
role="menu"
|
||||
aria-label={t('connection.switchServerTitle')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-muted)',
|
||||
padding: '6px 10px 4px',
|
||||
}}
|
||||
>
|
||||
{t('connection.switchServerTitle')}
|
||||
</div>
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const labelText = (srv.name || srv.url).trim() || srv.url;
|
||||
return (
|
||||
<button
|
||||
key={srv.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => onPickServer(srv)}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{labelText}</span>
|
||||
{switchingId === srv.id ? (
|
||||
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
|
||||
) : active ? (
|
||||
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
|
||||
) : (
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div style={{ borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)', marginTop: 2, paddingTop: 2 }} />
|
||||
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
|
||||
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2 } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
@@ -91,16 +91,28 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
|
||||
const newIds = songIds.filter((id) => !existingIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
|
||||
showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name }));
|
||||
} else {
|
||||
showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info');
|
||||
}
|
||||
touchPlaylist(pl.id);
|
||||
} catch {}
|
||||
} catch {
|
||||
showToast(t('playlists.addError'), 3000, 'error');
|
||||
}
|
||||
setAdding(null);
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
await createPlaylist(name, songIds);
|
||||
try {
|
||||
const pl = await createPlaylist(name, songIds);
|
||||
if (pl?.id) {
|
||||
showToast(t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }));
|
||||
}
|
||||
} catch {
|
||||
showToast(t('playlists.createError'), 3000, 'error');
|
||||
}
|
||||
onDone();
|
||||
};
|
||||
|
||||
@@ -1344,12 +1356,7 @@ 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]);
|
||||
}
|
||||
usePlayerStore.getState().reseedQueueForInstantMix(song);
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
try {
|
||||
const similar = await getSimilarSongs(song.id, 50);
|
||||
@@ -1535,6 +1542,105 @@ export default function ContextMenu() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'favorite-song' && (() => {
|
||||
const song = item as Track;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
if (!currentTrack) {
|
||||
playTrack(song, [song]);
|
||||
return;
|
||||
}
|
||||
const currentIdx = usePlayerStore.getState().queueIndex;
|
||||
const newQueue = [...queue];
|
||||
newQueue.splice(currentIdx + 1, 0, song);
|
||||
usePlayerStore.setState({ queue: newQueue });
|
||||
})}>
|
||||
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{song.artistId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
setStarredOverride(song.id, false);
|
||||
return unstar(song.id, 'song');
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'album' && (() => {
|
||||
const album = item as SubsonicAlbum;
|
||||
const albumRatingDisabled = entityRatingSupport === 'track_only';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
@@ -58,6 +59,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -141,8 +143,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<HeroBg url={stableBgUrl.current} />
|
||||
<div className="hero-overlay" aria-hidden="true" />
|
||||
{enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
@@ -20,6 +20,21 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
|
||||
const parts: string[] = [];
|
||||
const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
|
||||
if (track.replayGainTrackDb != null) {
|
||||
parts.push(t('queue.rgTrack', { db: fmtDb(track.replayGainTrackDb) }));
|
||||
}
|
||||
if (track.replayGainAlbumDb != null) {
|
||||
parts.push(t('queue.rgAlbum', { db: fmtDb(track.replayGainAlbumDb) }));
|
||||
}
|
||||
if (track.replayGainPeak != null) {
|
||||
parts.push(t('queue.rgPeak', { pk: track.replayGainPeak.toFixed(3) }));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderStars(rating?: number) {
|
||||
if (!rating) return null;
|
||||
const stars = [];
|
||||
@@ -236,6 +251,8 @@ export default function QueuePanel() {
|
||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
@@ -420,22 +437,45 @@ export default function QueuePanel() {
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
{(currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
<div className="queue-current-tech">
|
||||
{[
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
const bd = currentTrack.bitDepth;
|
||||
const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
|
||||
if (bd && sr) return `${bd}/${sr}`;
|
||||
if (bd) return `${bd}-bit`;
|
||||
if (sr) return sr;
|
||||
return undefined;
|
||||
})(),
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const baseParts = [
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
const bd = currentTrack.bitDepth;
|
||||
const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
|
||||
if (bd && sr) return `${bd}/${sr}`;
|
||||
if (bd) return `${bd}-bit`;
|
||||
if (sr) return sr;
|
||||
return undefined;
|
||||
})(),
|
||||
].filter(Boolean) as string[];
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
const techLine = [...baseParts, ...rgParts].join(' · ');
|
||||
if (!techLine && !playbackSource) return null;
|
||||
return (
|
||||
<div className="queue-current-tech">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
<span className="queue-current-tech-main">{techLine}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className="queue-current-cover">
|
||||
{currentTrack.coverArt ? (
|
||||
|
||||
+33
-23
@@ -3,37 +3,21 @@ import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
|
||||
Settings,
|
||||
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
import { getPlaylists } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
|
||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||
// Exported so Settings can render the same item metadata.
|
||||
export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey: string; to: string; section: 'library' | 'system' }> = {
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
|
||||
|
||||
export default function Sidebar({
|
||||
@@ -49,6 +33,12 @@ export default function Sidebar({
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
|
||||
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
||||
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
const isSyncing = syncJobStatus === 'running';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
@@ -57,6 +47,7 @@ export default function Sidebar({
|
||||
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||
const playlistsRaw = usePlaylistStore(s => s.playlists);
|
||||
@@ -131,10 +122,16 @@ export default function Sidebar({
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
.filter(cfg => {
|
||||
if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false;
|
||||
// Hide mode-inactive mix entries so the active mode controls what's shown
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
const visibleSystem = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
||||
.filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
|
||||
@@ -368,6 +365,19 @@ export default function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSyncing && (
|
||||
<div
|
||||
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
|
||||
data-tooltip-pos="right"
|
||||
>
|
||||
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||
{!isCollapsed && (
|
||||
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -93,6 +93,19 @@ export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'COMMUNITY',
|
||||
themes: [
|
||||
{ id: 'amber-night', label: 'Amber Night', bg: '#1a1410', card: '#201a12', accent: '#d4a96a' },
|
||||
{ id: 'amoled-black-pure', label: 'AMOLED Black Pure', bg: '#000000', card: '#000000', accent: '#ffffff' },
|
||||
{ id: 'ice-blue', label: 'Ice Blue', bg: '#0e1d28', card: '#132430', accent: '#7dd3e8' },
|
||||
{ id: 'midnight-blue', label: 'Midnight Blue', bg: '#0d1420', card: '#111a28', accent: '#60a5fa' },
|
||||
{ id: 'monochrome', label: 'Monochrome Dark', bg: '#161616', card: '#1c1c1c', accent: '#c0c0c0' },
|
||||
{ id: 'phosphor-green', label: 'Phosphor Green', bg: '#0d1a0d', card: '#111f11', accent: '#4ade80' },
|
||||
{ id: 'rose-dark', label: 'Rose Dark', bg: '#1a0d14', card: '#20111a', accent: '#f472b6' },
|
||||
{ id: 'sepia-dark', label: 'Sepia Dark', bg: '#1e1a14', card: '#252018', accent: '#c8b89a' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mediaplayer',
|
||||
themes: [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
|
||||
|
||||
function fmt(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
@@ -733,7 +732,10 @@ export function SeekbarPreview({
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
|
||||
let heights: Float32Array | null = null;
|
||||
if (style === 'waveform') {
|
||||
heights = makeHeights('seekbar-preview-demo');
|
||||
}
|
||||
const animState = makeAnimState();
|
||||
let t = 0;
|
||||
const tick = () => {
|
||||
@@ -818,7 +820,11 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
styleRef.current = seekbarStyle;
|
||||
|
||||
useEffect(() => {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
if (!trackId) {
|
||||
heightsRef.current = null;
|
||||
return;
|
||||
}
|
||||
heightsRef.current = makeHeights(trackId);
|
||||
}, [trackId]);
|
||||
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
@@ -869,6 +875,17 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return () => ro.disconnect();
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Theme change observer — redraw canvas when theme changes.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => observer.disconnect();
|
||||
}, [seekbarStyle]);
|
||||
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
const seekRef = useRef(seek);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Heart, BarChart3,
|
||||
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
|
||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItemMeta {
|
||||
icon: React.ElementType;
|
||||
labelKey: string;
|
||||
to: string;
|
||||
section: 'library' | 'system';
|
||||
}
|
||||
|
||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||
export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
folderBrowser:{ icon: FolderOpen, labelKey: 'sidebar.folderBrowser',to: '/folders', section: 'library' },
|
||||
deviceSync: { icon: HardDriveUpload,labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export const deTranslation = {
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
syncingTracks: 'Synchronisiere {{done}}/{{total}}…',
|
||||
cancelDownload: 'Download abbrechen',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
@@ -24,6 +25,7 @@ export const deTranslation = {
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Ordner-Browser',
|
||||
deviceSync: 'Gerätesync',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
expandPlaylists: 'Playlists ausklappen',
|
||||
@@ -99,6 +101,7 @@ export const deTranslation = {
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
||||
cliMixNeedsTrack: 'Es läuft nichts — starte zuerst die Wiedergabe und führe den Mix-Befehl erneut aus.',
|
||||
lfmLove: 'Auf Last.fm liken',
|
||||
lfmUnlove: 'Last.fm-Like entfernen',
|
||||
favorite: 'Favorisieren',
|
||||
@@ -151,6 +154,7 @@ export const deTranslation = {
|
||||
trackDuration: 'Dauer',
|
||||
trackTotal: 'Gesamt',
|
||||
columns: 'Spalten',
|
||||
resetColumns: 'Zurücksetzen',
|
||||
notFound: 'Album nicht gefunden.',
|
||||
bioModal: 'Künstler-Biografie',
|
||||
bioClose: 'Schließen',
|
||||
@@ -209,6 +213,11 @@ export const deTranslation = {
|
||||
playAll: 'Alle abspielen',
|
||||
removeSong: 'Aus Favoriten entfernen',
|
||||
stations: 'Radiosender',
|
||||
showingFiltered: 'Zeige {{filtered}} von {{total}} ({{artist}})',
|
||||
showingCount: 'Zeige {{filtered}} von {{total}}',
|
||||
clearArtistFilter: 'Künstlerfilter zurücksetzen',
|
||||
noFilterResults: 'Keine Ergebnisse mit den ausgewählten Filtern.',
|
||||
allArtists: 'Alle Künstler',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Mix erstellen',
|
||||
@@ -348,6 +357,10 @@ export const deTranslation = {
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
serverSettings: 'Server-Einstellungen',
|
||||
switchServerTitle: 'Server wechseln',
|
||||
switchServerHint: 'Klicken, um einen anderen gespeicherten Server zu wählen.',
|
||||
manageServers: 'Server verwalten…',
|
||||
switchFailed: 'Wechsel fehlgeschlagen — Server nicht erreichbar.',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
},
|
||||
@@ -375,6 +388,7 @@ export const deTranslation = {
|
||||
filterSearchGenres: 'Genres suchen…',
|
||||
filterNoGenres: 'Keine Genres gefunden',
|
||||
filterClear: 'Zurücksetzen',
|
||||
play: 'Abspielen',
|
||||
bulkSelected: '{{count}} ausgewählt',
|
||||
clearSelection: 'Auswahl aufheben',
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
@@ -397,6 +411,10 @@ export const deTranslation = {
|
||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||
durationMinutesOnly: '{{minutes}} Min.',
|
||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||
filters: 'Filter',
|
||||
more: 'mehr',
|
||||
yearRange: 'Jahresbereich',
|
||||
clearAll: 'Alles zurücksetzen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -521,6 +539,11 @@ export const deTranslation = {
|
||||
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
|
||||
discordAppleCovers: 'Cover über Apple Music für Discord laden',
|
||||
discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.',
|
||||
discordTemplates: 'Benutzerdefinierte Textvorlagen',
|
||||
discordTemplatesDesc: 'Passen Sie an, welche Informationen in Ihrem Discord-Profil angezeigt werden. Variablen: {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Primäre Zeile (details)',
|
||||
discordTemplateState: 'Sekundäre Zeile (state)',
|
||||
discordTemplateLargeText: 'Album-Tooltip (largeText)',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
lyricsServerFirst: 'Server-Lyrics bevorzugen',
|
||||
@@ -567,6 +590,8 @@ export const deTranslation = {
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
randomNavSplitTitle: 'Mix-Navigation aufteilen',
|
||||
randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
|
||||
tabInput: 'Eingabe',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
@@ -660,6 +685,13 @@ export const deTranslation = {
|
||||
themeSchedulerNightTheme: 'Nacht-Theme',
|
||||
themeSchedulerNightStart: 'Nacht beginnt um',
|
||||
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.',
|
||||
visualOptionsTitle: 'Visuelle Optionen',
|
||||
coverArtBackground: 'Cover-Hintergrund',
|
||||
coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen',
|
||||
playlistCoverPhoto: 'Playlist-Coverfoto',
|
||||
playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht',
|
||||
showBitrate: 'Bitrate anzeigen',
|
||||
showBitrateSub: 'Audio-Bitrate in Track-Listen anzeigen',
|
||||
uiScaleTitle: 'Interface-Skalierung',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -766,6 +798,32 @@ export const deTranslation = {
|
||||
a46: 'Der Hot Cache (Alpha, Einstellungen → Bibliothek) lädt die nächsten Songs der Warteschlange vorab auf die Festplatte, damit die Wiedergabe ohne Pufferverzögerung startet – besonders nützlich bei langsamen oder entfernten Servern. Psysonic hält den aktuellen Track und die nächsten 5 im Cache und verdrängt ältere Einträge, wenn das Größenlimit erreicht wird. Cache-Größe und Debounce-Verzögerung sind konfigurierbar.',
|
||||
q47: 'Kann ich eine Playlist offline cachen?',
|
||||
a47: 'Ja. In den Playlist-Details auf das Download-Symbol klicken – alle Tracks werden im Hintergrund heruntergeladen. Ist die Playlist vollständig gecacht, wechselt das Symbol zu einem roten Papierkorb; ein erneuter Klick entfernt die Playlist aus dem Offline-Cache.',
|
||||
q48: 'Was ist die Infinite Queue?',
|
||||
a48: 'Wenn die Warteschlange leer läuft und Wiederholen deaktiviert ist, fügt Psysonic automatisch zufällige Tracks aus deiner Bibliothek hinzu, damit die Wiedergabe nie stoppt. Automatisch hinzugefügte Tracks erscheinen unterhalb des Trenners „— Automatisch hinzugefügt —". Umschalten über das Unendlichkeitssymbol im Warteschlangen-Header. In Einstellungen → Warteschlange kann der automatische Nachschub auf ein bestimmtes Genre beschränkt werden.',
|
||||
q49: 'Kann ich Psysonic über die Kommandozeile bedienen?',
|
||||
a49: 'Ja – das Psysonic-Binary kann die laufende App fernsteuern. Beispiele: psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one, --player star / unstar, --player rating 1–5. Außerdem: Server wechseln (--player server list / set), Ausgabegerät wählen (--player audio-device list / set), Bibliotheks-Ordner filtern (--player library list / set), Instant Mix starten (--player mix new / append) und Suche (--player search track|album|artist <Suchbegriff>). Vollständige Hilfe: psysonic --player --help. Shell-Completions für bash und zsh sind über psysonic completions verfügbar.',
|
||||
q50: 'Wie verwalte ich Playlists?',
|
||||
a50: 'Über „Playlists" in der Seitenleiste. Mit „Neue Playlist" eine Playlist erstellen. In der Playlist-Detailansicht können Tracks per Drag & Drop umsortiert, mit dem ×-Button entfernt und über die Songsuche neue hinzugefügt werden. Die „Vorschläge"-Sektion macht Empfehlungen basierend auf dem Inhalt der Playlist. Rechtsklick auf einen Track für weitere Optionen.',
|
||||
q51: 'Kann ich meine Einstellungen sichern und wiederherstellen?',
|
||||
a51: 'Ja. Einstellungen → Backup & Restore ermöglicht den Export aller Einstellungen, Serverprofile, Themes und Tastenkürzel in eine JSON-Datei. Auf einem anderen Gerät oder nach einer Neuinstallation importieren, um alles auf einmal wiederherzustellen. Passwörter sind in der Sicherung enthalten – die Datei sicher aufbewahren.',
|
||||
q52: 'Wie ändere ich das Audioausgabegerät?',
|
||||
a52: 'Einstellungen → Audio → Ausgabegerät. Psysonic listet alle verfügbaren Ausgaben auf. Auswahl wechselt die Wiedergabe sofort. Den Aktualisierungsbutton daneben nutzen, wenn ein Gerät nach dem App-Start angeschlossen wurde. Unter Linux werden ALSA-Gerätenamen bereinigt angezeigt.',
|
||||
q53: 'Was ist Device Sync?',
|
||||
a53: 'Device Sync überträgt Alben, Playlists oder ganze Künstler auf einen USB-Stick oder eine SD-Karte. Über „Device Sync" in der Seitenleiste öffnen, einen Zielordner auf dem Laufwerk wählen, Quellen in den Browser-Tabs auswählen und auf „Auf Gerät übertragen" klicken. Psysonic lädt die Tracks vom Server und legt sie in der durch die Dateinamen-Vorlage definierten Ordnerstruktur ab.',
|
||||
q54: 'Was ist die Dateinamen-Vorlage in Device Sync?',
|
||||
a54: 'Die Dateinamen-Vorlage steuert, wie Tracks auf dem Gerät benannt und organisiert werden. Vorgaben (Standard, Multi-Disc, Alt. Ordner) auswählen oder selbst aus den klickbaren Token-Chips zusammenbauen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year} und / als Ordner-Trennzeichen. Eine Live-Vorschau zeigt, wie ein Beispiel-Track benannt würde.',
|
||||
q55: 'Funktioniert Device Sync plattformübergreifend?',
|
||||
a55: 'Ja. Das auf dem Gerät gespeicherte Manifest (psysonic-sync.json) enthält die beim Sync verwendete Dateinamen-Vorlage. Wird Device Sync auf einem anderen Betriebssystem mit anderer Vorlage geöffnet, erkennt Psysonic die bereits vorhandenen Dateien korrekt – Alben werden nicht fälschlich als ausstehend angezeigt.',
|
||||
s9: 'Device Sync',
|
||||
q56: 'Was ist Internet Radio?',
|
||||
a56: 'Die Internetradio-Seite (Seitenleiste) ermöglicht die Wiedergabe beliebiger Livestreams direkt in Psysonic. Sender werden vom Navidrome-Server bezogen – dort über das Admin-Panel hinzufügen oder eine .pls- / .m3u-Datei importieren. Die Wiedergabe erfolgt über HTML5-Audio, nicht den Rust-Audio-Engine – einige EQ- und Replay-Gain-Einstellungen gelten daher nicht für Radio.',
|
||||
q57: 'Welche Stream-Formate unterstützt Internet Radio?',
|
||||
a57: 'Psysonic unterstützt alle Formate, die der Audio-Stack des Systems dekodieren kann: MP3, AAC, OGG Vorbis und die meisten HLS-Streams. Da HTML5-Audio verwendet wird, hängt die Codec-Unterstützung von der Plattform ab – MP3 und AAC funktionieren überall.',
|
||||
s10: 'Internet Radio',
|
||||
q58: 'Woher bezieht Psysonic Songtexte?',
|
||||
a58: 'Psysonic unterstützt drei Lyrics-Quellen, alle konfigurierbar in Einstellungen → Lyrics: Navidrome-Server (eingebettete/OpenSubsonic-Lyrics), LRCLIB (community-gepflegte synchronisierte Texte) und NetEase Cloud Music (umfangreicher internationaler Katalog). Jede Quelle kann aktiviert/deaktiviert und per Drag & Drop priorisiert werden. Synchronisierte Lyrics scrollen automatisch und unterstützen Klick-zum-Springen.',
|
||||
q59: 'Gibt es eine Ansicht, was andere Nutzer gerade hören?',
|
||||
a59: 'Ja. Das Broadcast-Symbol oben rechts im Header öffnet das „Now Playing"-Dropdown. Es zeigt, was andere Nutzer auf dem Navidrome-Server gerade hören – aktualisiert alle 10 Sekunden. Der eigene Eintrag verschwindet beim Pausieren.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Warteschlange',
|
||||
@@ -797,6 +855,12 @@ export const deTranslation = {
|
||||
trackPlural: 'Titel',
|
||||
showRemaining: 'Restzeit anzeigen',
|
||||
showTotal: 'Gesamtzeit anzeigen',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Peak {{pk}}',
|
||||
sourceOffline: 'Wiedergabe aus der Offline-Bibliothek',
|
||||
sourceHot: 'Wiedergabe aus dem Cache',
|
||||
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
@@ -968,6 +1032,24 @@ export const deTranslation = {
|
||||
addToPlaylistError: 'Fehler beim Hinzufügen zur Playlist',
|
||||
removeSuccess: 'Song aus Playlist entfernt',
|
||||
removeError: 'Fehler beim Entfernen aus der Playlist',
|
||||
importCSV: 'CSV importieren',
|
||||
importCSVTooltip: 'Aus Spotify-CSV importieren',
|
||||
csvImportReport: 'CSV-Importbericht',
|
||||
csvImportTotal: 'Gesamt',
|
||||
csvImportAdded: 'Hinzugefügt',
|
||||
csvImportDuplicates: 'Duplikate',
|
||||
csvImportNotFound: 'Nicht gefunden',
|
||||
csvImportNetworkErrors: 'Netzwerkfehler',
|
||||
csvImportDuplicatesTitle: 'Doppelte Titel (bereits in Playlist):',
|
||||
csvImportNotFoundTitle: 'Nicht gefundene Titel:',
|
||||
csvImportNetworkErrorsTitle: 'Netzwerkfehler (Import kann wiederholt werden):',
|
||||
csvImportDownloadReport: 'Bericht herunterladen',
|
||||
csvImportClose: 'Schließen',
|
||||
csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden',
|
||||
csvImportFailed: 'CSV-Import fehlgeschlagen',
|
||||
csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate',
|
||||
csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen',
|
||||
csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meistgehört',
|
||||
@@ -1021,4 +1103,73 @@ export const deTranslation = {
|
||||
empty: 'Leerer Ordner',
|
||||
error: 'Laden fehlgeschlagen',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Gerätesync',
|
||||
targetFolder: 'Zielordner',
|
||||
noFolderChosen: 'Kein Ordner gewählt',
|
||||
selectDrive: 'Laufwerk auswählen…',
|
||||
refreshDrives: 'Laufwerke aktualisieren',
|
||||
noDrivesDetected: 'Keine Wechseldatenträger erkannt',
|
||||
browseManual: 'Manuell durchsuchen…',
|
||||
free: 'frei',
|
||||
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
|
||||
chooseFolder: 'Auswählen…',
|
||||
filenameTemplate: 'Dateinamen-Vorlage',
|
||||
targetDevice: 'Zielgerät',
|
||||
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
onDevice: 'Auf dem Gerät',
|
||||
addSources: 'Hinzufügen…',
|
||||
colName: 'Name',
|
||||
colType: 'Typ',
|
||||
syncResult: '{{done}} übertragen, {{skipped}} bereits aktuell ({{total}} gesamt)',
|
||||
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
|
||||
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
|
||||
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
|
||||
manifestImported: '{{count}} Quelle(n) aus Geräte-Manifest importiert.',
|
||||
selectedSources: 'Ausgewählte Quellen',
|
||||
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
|
||||
clearAll: 'Alle entfernen',
|
||||
tabPlaylists: 'Wiedergabelisten',
|
||||
tabAlbums: 'Alben',
|
||||
tabArtists: 'Künstler',
|
||||
searchPlaceholder: 'Suche…',
|
||||
syncButton: 'Auf Gerät übertragen',
|
||||
actionTransfer: 'Auf Gerät übertragen',
|
||||
actionDelete: 'Vom Gerät löschen',
|
||||
actionApplyAll: 'Änderungen synchronisieren',
|
||||
templatePreview: 'Vorschau',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. Ordner',
|
||||
tokenSlashHint: '/ = Ordner-Trennzeichen',
|
||||
cancel: 'Abbrechen',
|
||||
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
|
||||
noSources: 'Bitte mindestens eine Quelle auswählen.',
|
||||
noTracks: 'Keine Tracks in den ausgewählten Quellen gefunden.',
|
||||
fetchError: 'Fehler beim Laden der Tracks vom Server.',
|
||||
syncComplete: 'Übertragung abgeschlossen.',
|
||||
dismiss: 'Schließen',
|
||||
cancelSync: 'Abbrechen',
|
||||
syncCancelled: 'Sync abgebrochen ({{done}} / {{total}} übertragen).',
|
||||
colStatus: 'Status',
|
||||
statusSynced: 'Synchronisiert',
|
||||
statusPending: 'Ausstehend',
|
||||
statusDeletion: 'Löschung',
|
||||
markForDeletion: 'Zur Löschung markieren',
|
||||
removeSource: 'Entfernen',
|
||||
undoDeletion: 'Löschung rückgängig',
|
||||
syncInBackground: 'Sync gestartet — du kannst die Seite verlassen.',
|
||||
syncInProgress: '{{done}} / {{total}} Tracks…',
|
||||
syncSummary: 'Sync-Zusammenfassung',
|
||||
calculating: 'Payload wird berechnet…',
|
||||
filesToAdd: 'Hinzuzufügende Dateien:',
|
||||
filesToDelete: 'Zu löschende Dateien:',
|
||||
netChange: 'Nettoänderung:',
|
||||
availableSpace: 'Verfügbarer Speicher:',
|
||||
spaceWarning: 'Warnung: Das Zielgerät hat nicht genug gemeldeten Speicherplatz.',
|
||||
proceed: 'Sync durchführen',
|
||||
notEnoughSpace: 'Nicht genug physischer Speicherplatz erkannt!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Zufallsalben',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const enTranslation = {
|
||||
collapse: 'Collapse Sidebar',
|
||||
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
syncingTracks: 'Syncing {{done}}/{{total}}…',
|
||||
cancelDownload: 'Cancel download',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
@@ -25,6 +26,7 @@ export const enTranslation = {
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
folderBrowser: 'Folder Browser',
|
||||
deviceSync: 'Device Sync',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
expandPlaylists: 'Expand playlists',
|
||||
@@ -100,6 +102,7 @@ export const enTranslation = {
|
||||
startRadio: 'Start Radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
||||
cliMixNeedsTrack: 'Nothing is playing — start playback first, then run the mix command again.',
|
||||
lfmLove: 'Love on Last.fm',
|
||||
lfmUnlove: 'Unlove on Last.fm',
|
||||
favorite: 'Favorite',
|
||||
@@ -152,6 +155,7 @@ export const enTranslation = {
|
||||
trackDuration: 'Duration',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Columns',
|
||||
resetColumns: 'Reset to defaults',
|
||||
notFound: 'Album not found.',
|
||||
bioModal: 'Artist Biography',
|
||||
bioClose: 'Close',
|
||||
@@ -210,6 +214,11 @@ export const enTranslation = {
|
||||
playAll: 'Play all',
|
||||
removeSong: 'Remove from favorites',
|
||||
stations: 'Radio Stations',
|
||||
showingFiltered: 'Showing {{filtered}} of {{total}} ({{artist}})',
|
||||
showingCount: 'Showing {{filtered}} of {{total}}',
|
||||
clearArtistFilter: 'Clear artist filter',
|
||||
noFilterResults: 'No results with selected filters.',
|
||||
allArtists: 'All Artists',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Build a Mix',
|
||||
@@ -349,6 +358,10 @@ export const enTranslation = {
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
serverSettings: 'Server Settings',
|
||||
switchServerTitle: 'Switch server',
|
||||
switchServerHint: 'Click to choose another saved server.',
|
||||
manageServers: 'Manage servers…',
|
||||
switchFailed: 'Could not switch — server unreachable.',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
},
|
||||
@@ -376,6 +389,7 @@ export const enTranslation = {
|
||||
filterSearchGenres: 'Search genres…',
|
||||
filterNoGenres: 'No genres match',
|
||||
filterClear: 'Clear',
|
||||
play: 'Play',
|
||||
bulkSelected: '{{count}} selected',
|
||||
clearSelection: 'Clear selection',
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
@@ -398,6 +412,10 @@ export const enTranslation = {
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
filters: 'Filters',
|
||||
more: 'more',
|
||||
yearRange: 'Year Range',
|
||||
clearAll: 'Clear all',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -523,6 +541,11 @@ export const enTranslation = {
|
||||
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
|
||||
discordAppleCovers: 'Fetch covers from Apple Music for Discord',
|
||||
discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.',
|
||||
discordTemplates: 'Custom text templates',
|
||||
discordTemplatesDesc: 'Customize what information is shown on your Discord profile. Variables: {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Primary line (details)',
|
||||
discordTemplateState: 'Secondary line (state)',
|
||||
discordTemplateLargeText: 'Album tooltip (largeText)',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
lyricsServerFirst: 'Prefer server lyrics',
|
||||
@@ -569,6 +592,8 @@ export const enTranslation = {
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
randomNavSplitTitle: 'Split Mix navigation',
|
||||
randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.',
|
||||
tabInput: 'Input',
|
||||
tabServer: 'Server',
|
||||
tabSystem: 'System',
|
||||
@@ -662,6 +687,13 @@ export const enTranslation = {
|
||||
themeSchedulerNightTheme: 'Night Theme',
|
||||
themeSchedulerNightStart: 'Night Starts At',
|
||||
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.',
|
||||
visualOptionsTitle: 'Visual Options',
|
||||
coverArtBackground: 'Cover Art Background',
|
||||
coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers',
|
||||
playlistCoverPhoto: 'Playlist Cover Photo',
|
||||
playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view',
|
||||
showBitrate: 'Show Bitrate',
|
||||
showBitrateSub: 'Display audio bitrate in track listings',
|
||||
uiScaleTitle: 'Interface Scale',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -768,6 +800,32 @@ export const enTranslation = {
|
||||
a46: 'Hot Cache (Alpha, Settings → Library) preloads the next several tracks in your queue to disk so playback starts instantly with no buffering delay — especially useful on slow or remote servers. Psysonic keeps the current track and the next 5 in cache and evicts older entries when the size limit is reached. You can set the maximum cache size and a debounce delay to avoid unnecessary fetches when skipping quickly.',
|
||||
q47: 'Can I cache a playlist for offline use?',
|
||||
a47: 'Yes. Open any playlist and click the download icon in the playlist header. All tracks are downloaded in the background — progress is shown in the offline download indicator. Once fully cached, the icon changes to a red trash icon; clicking it removes the playlist from the offline cache.',
|
||||
q48: 'What is Infinite Queue?',
|
||||
a48: 'When the queue runs out and Repeat is off, Psysonic silently appends random tracks from your library so playback never stops. Auto-added tracks appear below a "— Added automatically —" divider in the queue. Toggle it with the infinity icon in the queue header. You can also restrict auto-added tracks to a specific genre in Settings → Queue.',
|
||||
q49: 'Can I control Psysonic from the command line?',
|
||||
a49: 'Yes — the Psysonic binary doubles as a remote control for the running app. Examples: psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one, --player star / unstar, --player rating 1-5. You can also switch servers (--player server list / set), change the audio device (--player audio-device list / set), filter the music library (--player library list / set), trigger an Instant Mix (--player mix new / append), and search (--player search track|album|artist <query>). Run psysonic --player --help for the full list. Shell completions for bash and zsh are available via psysonic completions.',
|
||||
q50: 'How do I manage playlists?',
|
||||
a50: 'Open "Playlists" in the sidebar. Click "New Playlist" to create one. On a playlist detail page you can drag tracks to reorder them, remove tracks with the × button, search your library to add songs, and use "Suggestions" to get smart track recommendations based on your playlist content. Right-click any track for more options.',
|
||||
q51: 'Can I back up and restore my settings?',
|
||||
a51: 'Yes. Settings → Backup & Restore lets you export all your settings, server profiles, themes, and keybindings to a single JSON file. Import it on another machine or after a reinstall to restore everything at once. Passwords are included in the backup — store the file securely.',
|
||||
q52: 'How do I change the audio output device?',
|
||||
a52: 'Settings → Audio → Output Device. Psysonic lists all available audio outputs on your system. Select the one you want — playback switches immediately. Use the refresh button next to the list if a device was connected after the app started. On Linux this uses ALSA device names; generic entries like "default" or "sysdefault" are automatically cleaned up for readability.',
|
||||
q53: 'What is Device Sync?',
|
||||
a53: 'Device Sync lets you copy albums, playlists, or entire artists to a USB drive or SD card for listening on other devices. Open it via "Device Sync" in the sidebar. Select a target folder on your drive, choose sources from the browser tabs, and click "Transfer to Device". Psysonic downloads the tracks from your server and writes them in the folder structure defined by the filename template.',
|
||||
q54: 'What is the filename template in Device Sync?',
|
||||
a54: 'The filename template controls how tracks are named and organized on the device. Use the preset buttons (Standard, Multi-Disc, Alt. Folder) or build your own using the clickable token chips: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}, and / for folder separators. A live preview shows how an example track would be named.',
|
||||
q55: 'Does Device Sync work across platforms?',
|
||||
a55: 'Yes. The sync manifest (psysonic-sync.json) written to the device stores the filename template used during sync. When you open Device Sync on a different OS with a different template, Psysonic uses the stored template to correctly detect which files are already on the device — so albums don\'t show as pending when they are already synced.',
|
||||
s9: 'Device Sync',
|
||||
q56: 'What is Internet Radio?',
|
||||
a56: 'The Internet Radio page (sidebar) lets you play any live stream directly in Psysonic. Stations are fetched from your Navidrome server — add them there via the Navidrome admin panel, or import a .pls / .m3u file. Playback uses the browser\'s built-in HTML5 audio engine rather than the Rust audio engine, so some EQ and Replay Gain settings do not apply to radio.',
|
||||
q57: 'What stream formats does Internet Radio support?',
|
||||
a57: 'Psysonic supports any format your system\'s audio stack can decode: MP3, AAC, OGG Vorbis, and most HLS streams. The stream is played via HTML5 <audio>, so codec support depends on the platform — MP3 and AAC work everywhere.',
|
||||
s10: 'Internet Radio',
|
||||
q58: 'Where does Psysonic fetch lyrics from?',
|
||||
a58: 'Psysonic supports three lyrics sources, all configurable in Settings → Lyrics: your Navidrome server (embedded/OpenSubsonic lyrics), LRCLIB (community-contributed synced lyrics), and NetEase Cloud Music (large Chinese and international catalog). You can enable or disable each source and reorder their priority by dragging. Synced lyrics auto-scroll and support click-to-seek; plain-text lyrics are shown statically.',
|
||||
q59: 'Is there a Now Playing view for other users?',
|
||||
a59: 'Yes. Click the broadcast icon in the top-right header to open the "Now Playing" dropdown. It shows what other users on your Navidrome server are currently listening to, updated every 10 seconds. Your own entry disappears when you pause.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Queue',
|
||||
@@ -799,6 +857,12 @@ export const enTranslation = {
|
||||
trackPlural: 'tracks',
|
||||
showRemaining: 'Show remaining time',
|
||||
showTotal: 'Show total time',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Peak {{pk}}',
|
||||
sourceOffline: 'Playing from offline library',
|
||||
sourceHot: 'Playing from cache',
|
||||
sourceStream: 'Playing from network stream',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
@@ -970,6 +1034,24 @@ export const enTranslation = {
|
||||
addToPlaylistError: 'Error adding to playlist',
|
||||
removeSuccess: 'Song removed from playlist',
|
||||
removeError: 'Error removing song from playlist',
|
||||
importCSV: 'Import CSV',
|
||||
importCSVTooltip: 'Import from Spotify CSV',
|
||||
csvImportReport: 'CSV Import Report',
|
||||
csvImportTotal: 'Total',
|
||||
csvImportAdded: 'Added',
|
||||
csvImportDuplicates: 'Duplicates',
|
||||
csvImportNotFound: 'Not Found',
|
||||
csvImportNetworkErrors: 'Network Errors',
|
||||
csvImportDuplicatesTitle: 'Duplicate Tracks (Already in Playlist):',
|
||||
csvImportNotFoundTitle: 'Tracks Not Found:',
|
||||
csvImportNetworkErrorsTitle: 'Network Errors (may retry import):',
|
||||
csvImportDownloadReport: 'Download Report',
|
||||
csvImportClose: 'Close',
|
||||
csvImportNoValidTracks: 'No valid tracks found in CSV file',
|
||||
csvImportFailed: 'Failed to import CSV file',
|
||||
csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates',
|
||||
csvImportDownloadSuccess: 'Report downloaded successfully',
|
||||
csvImportDownloadError: 'Failed to download report',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Most Played',
|
||||
@@ -1023,4 +1105,74 @@ export const enTranslation = {
|
||||
empty: 'Empty folder',
|
||||
error: 'Failed to load',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Device Sync',
|
||||
targetFolder: 'Target Folder',
|
||||
noFolderChosen: 'No folder chosen',
|
||||
selectDrive: 'Select a drive…',
|
||||
refreshDrives: 'Refresh drives',
|
||||
noDrivesDetected: 'No removable drives detected',
|
||||
browseManual: 'Browse manually…',
|
||||
free: 'free',
|
||||
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
|
||||
chooseFolder: 'Choose…',
|
||||
filenameTemplate: 'Filename Template',
|
||||
targetDevice: 'Target Device',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
onDevice: 'Device Manager',
|
||||
addSources: 'Add…',
|
||||
colName: 'Name',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)',
|
||||
deleteFromDevice: 'Mark for deletion ({{count}})',
|
||||
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
|
||||
deleteComplete: '{{count}} item(s) removed from device.',
|
||||
manifestImported: 'Imported {{count}} source(s) from device manifest.',
|
||||
selectedSources: 'Selected Sources',
|
||||
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
|
||||
clearAll: 'Clear all',
|
||||
tabPlaylists: 'Playlists',
|
||||
tabAlbums: 'Albums',
|
||||
tabArtists: 'Artists',
|
||||
searchPlaceholder: 'Search…',
|
||||
syncButton: 'Sync to Device',
|
||||
actionTransfer: 'Transfer to Device',
|
||||
actionDelete: 'Delete from Device',
|
||||
actionApplyAll: 'Apply All Changes',
|
||||
templatePreview: 'Preview',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. Folder',
|
||||
tokenSlashHint: '/ = folder separator',
|
||||
cancel: 'Cancel',
|
||||
noTargetDir: 'Please choose a target folder first.',
|
||||
noSources: 'Please select at least one source.',
|
||||
noTracks: 'No tracks found in the selected sources.',
|
||||
fetchError: 'Failed to fetch tracks from server.',
|
||||
syncComplete: 'Sync complete.',
|
||||
dismiss: 'Dismiss',
|
||||
cancelSync: 'Cancel',
|
||||
syncCancelled: 'Sync cancelled ({{done}} / {{total}} transferred).',
|
||||
statusSynced: 'Synced',
|
||||
statusPending: 'Pending',
|
||||
statusDeletion: 'Deletion',
|
||||
markForDeletion: 'Mark for deletion',
|
||||
undoDeletion: 'Undo deletion',
|
||||
removeSource: 'Remove',
|
||||
syncInBackground: 'Sync started in background — you can navigate away.',
|
||||
syncInProgress: '{{done}} / {{total}} tracks…',
|
||||
scanningDevice: 'Scanning device…',
|
||||
syncSummary: 'Sync Summary',
|
||||
calculating: 'Calculating required payload…',
|
||||
filesToAdd: 'Files to Add:',
|
||||
filesToDelete: 'Files to Delete:',
|
||||
netChange: 'Net Change:',
|
||||
availableSpace: 'Available Disk Space:',
|
||||
spaceWarning: 'Warning: Target device does not have enough reported space.',
|
||||
proceed: 'Proceed with Sync',
|
||||
notEnoughSpace: 'Not enough physical disk space detected!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Random Albums',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const esTranslation = {
|
||||
collapse: 'Colapsar Barra Lateral',
|
||||
|
||||
downloadingTracks: 'Descargando {{n}} pistas…',
|
||||
syncingTracks: 'Sincronizando {{done}}/{{total}}…',
|
||||
cancelDownload: 'Cancelar descarga',
|
||||
offlineLibrary: 'Biblioteca Offline',
|
||||
genres: 'Géneros',
|
||||
@@ -25,6 +26,7 @@ export const esTranslation = {
|
||||
mostPlayed: 'Más Reproducidos',
|
||||
radio: 'Radio por Internet',
|
||||
folderBrowser: 'Explorar Carpetas',
|
||||
deviceSync: 'Sincronizar dispositivo',
|
||||
libraryScope: 'Ámbito de biblioteca',
|
||||
allLibraries: 'Todas las bibliotecas',
|
||||
expandPlaylists: 'Expandir listas',
|
||||
@@ -100,6 +102,7 @@ export const esTranslation = {
|
||||
startRadio: 'Iniciar Radio',
|
||||
instantMix: 'Mezcla Instantánea',
|
||||
instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.',
|
||||
cliMixNeedsTrack: 'No hay nada en reproducción — inicia la reproducción y vuelve a ejecutar el comando de mezcla.',
|
||||
lfmLove: 'Favorito en Last.fm',
|
||||
lfmUnlove: 'Quitar favorito de Last.fm',
|
||||
favorite: 'Favorito',
|
||||
@@ -152,6 +155,7 @@ export const esTranslation = {
|
||||
trackDuration: 'Duración',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Columnas',
|
||||
resetColumns: 'Restablecer',
|
||||
notFound: 'Álbum no encontrado.',
|
||||
bioModal: 'Biografía del Artista',
|
||||
bioClose: 'Cerrar',
|
||||
@@ -210,6 +214,11 @@ export const esTranslation = {
|
||||
playAll: 'Reproducir todo',
|
||||
removeSong: 'Quitar de favoritos',
|
||||
stations: 'Estaciones de Radio',
|
||||
showingFiltered: 'Mostrando {{filtered}} de {{total}} ({{artist}})',
|
||||
showingCount: 'Mostrando {{filtered}} de {{total}}',
|
||||
clearArtistFilter: 'Limpiar filtro de artista',
|
||||
noFilterResults: 'No hay resultados con los filtros seleccionados.',
|
||||
allArtists: 'Todos los Artistas',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Crear Mezcla',
|
||||
@@ -350,6 +359,10 @@ export const esTranslation = {
|
||||
offlineFilterArtists: 'Discografías',
|
||||
retry: 'Reintentar',
|
||||
serverSettings: 'Configuración del servidor',
|
||||
switchServerTitle: 'Cambiar servidor',
|
||||
switchServerHint: 'Clic para elegir otro servidor guardado.',
|
||||
manageServers: 'Gestionar servidores…',
|
||||
switchFailed: 'No se pudo cambiar — servidor inalcanzable.',
|
||||
lastfmConnected: 'Last.fm conectado como @{{user}}',
|
||||
lastfmSessionInvalid: 'Sesión inválida — click para reconectar',
|
||||
},
|
||||
@@ -377,6 +390,7 @@ export const esTranslation = {
|
||||
filterSearchGenres: 'Buscar géneros…',
|
||||
filterNoGenres: 'Ningún género coincide',
|
||||
filterClear: 'Limpiar',
|
||||
play: 'Reproducir',
|
||||
bulkSelected: '{{count}} seleccionados',
|
||||
clearSelection: 'Limpiar selección',
|
||||
bulkAddToPlaylist: 'Agregar a Lista',
|
||||
@@ -399,6 +413,10 @@ export const esTranslation = {
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Abrir en GitHub',
|
||||
filters: 'Filtros',
|
||||
more: 'más',
|
||||
yearRange: 'Rango de años',
|
||||
clearAll: 'Limpiar todo',
|
||||
},
|
||||
settings: {
|
||||
title: 'Configuración',
|
||||
@@ -524,6 +542,11 @@ export const esTranslation = {
|
||||
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
|
||||
discordAppleCovers: 'Obtener portadas de Apple Music para Discord',
|
||||
discordAppleCoversDesc: 'Envía el artista y nombre del álbum a la API de búsqueda de Apple para encontrar portadas para tu perfil de Discord. Desactivado por defecto por privacidad.',
|
||||
discordTemplates: 'Plantillas de texto personalizadas',
|
||||
discordTemplatesDesc: 'Personaliza qué información se muestra en tu perfil de Discord. Variables: {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Línea principal (details)',
|
||||
discordTemplateState: 'Línea secundaria (state)',
|
||||
discordTemplateLargeText: 'Tooltip del álbum (largeText)',
|
||||
nowPlayingEnabled: 'Mostrar en Reproduciendo Ahora',
|
||||
nowPlayingEnabledDesc: 'Transmite tu pista actual a la vista de oyentes en vivo del servidor. Desactiva para dejar de enviar datos de reproducción.',
|
||||
lyricsServerFirst: 'Preferir letras del servidor',
|
||||
@@ -570,6 +593,8 @@ export const esTranslation = {
|
||||
sidebarReset: 'Restablecer a predeterminado',
|
||||
sidebarDrag: 'Arrastra para reordenar',
|
||||
sidebarFixed: 'Siempre visible',
|
||||
randomNavSplitTitle: 'Dividir navegación Mix',
|
||||
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
|
||||
tabInput: 'Entrada',
|
||||
tabServer: 'Servidor',
|
||||
tabSystem: 'Sistema',
|
||||
@@ -663,6 +688,13 @@ export const esTranslation = {
|
||||
themeSchedulerNightTheme: 'Tema de Noche',
|
||||
themeSchedulerNightStart: 'Comienza de Noche A las',
|
||||
themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.',
|
||||
visualOptionsTitle: 'Opciones Visuales',
|
||||
coverArtBackground: 'Fondo de Portada',
|
||||
coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción',
|
||||
playlistCoverPhoto: 'Foto de Portada de Playlist',
|
||||
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
|
||||
showBitrate: 'Mostrar Bitrate',
|
||||
showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas',
|
||||
uiScaleTitle: 'Escala de Interfaz',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -769,6 +801,32 @@ export const esTranslation = {
|
||||
a46: 'La Caché Activa (Alpha, Configuración → Biblioteca) precarga las siguientes varias pistas en tu cola al disco para que la reproducción comience instantáneamente sin demora de carga — especialmente útil en servidores lentos o remotos. Psysonic mantiene la pista actual y las siguientes 5 en caché y elimina entradas antiguas cuando se alcanza el límite de tamaño. Puedes establecer el tamaño máximo de caché y una demora de espera para evitar cargas innecesarias cuando saltas rápidamente.',
|
||||
q47: '¿Puedo almacenar una lista de reproducción para uso offline?',
|
||||
a47: 'Sí. Abre cualquier lista de reproducción y click en el icono de descarga en el encabezado de la lista. Todas las pistas se descargan en segundo plano — el progreso se muestra en el indicador de descarga offline. Una vez completado, el icono cambia a un ícono de basura rojo; click en él elimina la lista del caché offline.',
|
||||
q48: '¿Qué es la Cola Infinita?',
|
||||
a48: 'Cuando la cola se vacía y la repetición está desactivada, Psysonic añade pistas aleatorias automáticamente para que la reproducción nunca se detenga. Las pistas añadidas aparecen bajo el separador "— Añadido automáticamente —". Actívalo con el ícono de infinito en el encabezado de la cola.',
|
||||
q49: '¿Puedo controlar Psysonic desde la línea de comandos?',
|
||||
a49: 'Sí. Ejemplos: psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one. También: cambiar servidor, dispositivo de audio, filtrar biblioteca y buscar. Ayuda completa: psysonic --player --help.',
|
||||
q50: '¿Cómo gestiono las listas de reproducción?',
|
||||
a50: 'A través de "Listas de reproducción" en la barra lateral. Crea una con "Nueva lista". En la vista de detalle puedes reordenar pistas arrastrando, eliminarlas con ×, añadir canciones mediante búsqueda y usar "Sugerencias" para recomendaciones inteligentes.',
|
||||
q51: '¿Puedo hacer copia de seguridad y restaurar mis ajustes?',
|
||||
a51: 'Sí. Ajustes → Copia de seguridad y restauración exporta todos los ajustes, perfiles de servidor, temas y atajos a un archivo JSON. Impórtalo en otro dispositivo para restaurarlo todo de una vez.',
|
||||
q52: '¿Cómo cambio el dispositivo de salida de audio?',
|
||||
a52: 'Ajustes → Audio → Dispositivo de salida. Psysonic lista todas las salidas disponibles. La selección surte efecto inmediatamente. Usa el botón de actualizar si conectaste un dispositivo después de iniciar la app.',
|
||||
q53: '¿Qué es Device Sync?',
|
||||
a53: 'Device Sync copia álbumes, listas de reproducción o artistas a un USB o tarjeta SD. Ábrelo desde "Device Sync" en la barra lateral, elige una carpeta destino, selecciona fuentes y haz clic en "Transferir al dispositivo".',
|
||||
q54: '¿Qué es la plantilla de nombre de archivo en Device Sync?',
|
||||
a54: 'La plantilla controla cómo se nombran y organizan las pistas. Usa los preajustes o crea el tuyo con los tokens: {artist}, {album}, {title}, {track_number}, {disc_number}, {year} y / como separador de carpeta.',
|
||||
q55: '¿Funciona Device Sync entre plataformas?',
|
||||
a55: 'Sí. El manifiesto guardado en el dispositivo incluye la plantilla usada. En otro SO con distinta plantilla, Psysonic detecta correctamente los archivos existentes.',
|
||||
s9: 'Device Sync',
|
||||
q56: '¿Qué es la Radio por Internet?',
|
||||
a56: 'La página de Radio por Internet permite reproducir cualquier transmisión en vivo en Psysonic. Las estaciones provienen de tu servidor Navidrome. La reproducción usa audio HTML5.',
|
||||
q57: '¿Qué formatos de transmisión admite la Radio por Internet?',
|
||||
a57: 'MP3, AAC, OGG Vorbis y la mayoría de transmisiones HLS. MP3 y AAC funcionan en todas las plataformas.',
|
||||
s10: 'Radio por Internet',
|
||||
q58: '¿De dónde obtiene Psysonic las letras?',
|
||||
a58: 'Tres fuentes configurables en Ajustes → Letras: tu servidor Navidrome, LRCLIB y NetEase Cloud Music. Cada fuente puede activarse/desactivarse y priorizarse arrastrando.',
|
||||
q59: '¿Hay una vista de lo que escuchan otros usuarios?',
|
||||
a59: 'Sí. Haz clic en el ícono de transmisión en la parte superior derecha para ver qué escuchan otros usuarios en tu servidor Navidrome, actualizado cada 10 segundos.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Cola',
|
||||
@@ -800,6 +858,12 @@ export const esTranslation = {
|
||||
trackPlural: 'pistas',
|
||||
showRemaining: 'Mostrar tiempo restante',
|
||||
showTotal: 'Mostrar tiempo total',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Pico {{pk}}',
|
||||
sourceOffline: 'Reproducción desde la biblioteca sin conexión',
|
||||
sourceHot: 'Reproducción desde la caché',
|
||||
sourceStream: 'Reproducción desde la transmisión en red',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Estadísticas',
|
||||
@@ -971,6 +1035,24 @@ export const esTranslation = {
|
||||
addToPlaylistError: 'Error al agregar a la lista',
|
||||
removeSuccess: 'Canción quitada de la playlist',
|
||||
removeError: 'Error al quitar la canción de la playlist',
|
||||
importCSV: 'Importar CSV',
|
||||
importCSVTooltip: 'Importar desde CSV de Spotify',
|
||||
csvImportReport: 'Reporte de Importación CSV',
|
||||
csvImportTotal: 'Total',
|
||||
csvImportAdded: 'Agregadas',
|
||||
csvImportDuplicates: 'Duplicadas',
|
||||
csvImportNotFound: 'No Encontradas',
|
||||
csvImportNetworkErrors: 'Errores de Red',
|
||||
csvImportDuplicatesTitle: 'Canciones Duplicadas (Ya en la Lista):',
|
||||
csvImportNotFoundTitle: 'Canciones No Encontradas:',
|
||||
csvImportNetworkErrorsTitle: 'Errores de Red (puede reintentar la importación):',
|
||||
csvImportDownloadReport: 'Descargar Reporte',
|
||||
csvImportClose: 'Cerrar',
|
||||
csvImportNoValidTracks: 'No se encontraron canciones válidas en el archivo CSV',
|
||||
csvImportFailed: 'Error al importar el archivo CSV',
|
||||
csvImportToast: '{{added}} agregadas, {{notFound}} no encontradas, {{duplicates}} duplicadas',
|
||||
csvImportDownloadSuccess: 'Reporte descargado exitosamente',
|
||||
csvImportDownloadError: 'Error al descargar el reporte',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Más Reproducidos',
|
||||
@@ -1024,4 +1106,77 @@ export const esTranslation = {
|
||||
empty: 'Carpeta vacía',
|
||||
error: 'Error al cargar',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Sincronizar dispositivo',
|
||||
targetFolder: 'Carpeta de destino',
|
||||
noFolderChosen: 'Ninguna carpeta seleccionada',
|
||||
selectDrive: 'Seleccionar unidad…',
|
||||
refreshDrives: 'Actualizar unidades',
|
||||
noDrivesDetected: 'No se detectaron unidades extraíbles',
|
||||
browseManual: 'Explorar manualmente…',
|
||||
free: 'libre',
|
||||
notMountedVolume: 'El destino no está en un volumen montado. La unidad puede haberse desconectado.',
|
||||
chooseFolder: 'Elegir…',
|
||||
filenameTemplate: 'Plantilla de nombre de archivo',
|
||||
targetDevice: 'Dispositivo de destino',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Eliminar archivos fuera de la selección',
|
||||
cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.',
|
||||
confirmCleanup: '¿Eliminar {{count}} archivo(s) del dispositivo que no están en la selección actual? ¿Continuar?',
|
||||
cleanupComplete: '{{count}} archivo(s) eliminado(s) del dispositivo.',
|
||||
selectedSources: 'Fuentes seleccionadas',
|
||||
noSourcesSelected: 'No hay fuentes seleccionadas. Haz clic en elementos del navegador para agregarlos.',
|
||||
clearAll: 'Limpiar todo',
|
||||
tabPlaylists: 'Listas de reproducción',
|
||||
tabAlbums: 'Álbumes',
|
||||
tabArtists: 'Artistas',
|
||||
searchPlaceholder: 'Buscar…',
|
||||
syncButton: 'Sincronizar al dispositivo',
|
||||
actionTransfer: 'Transferir al dispositivo',
|
||||
actionDelete: 'Eliminar del dispositivo',
|
||||
actionApplyAll: 'Aplicar todos los cambios',
|
||||
templatePreview: 'Vista previa',
|
||||
templatePresetStandard: 'Estándar',
|
||||
templatePresetMultiDisc: 'Multi-Disco',
|
||||
templatePresetAltFolder: 'Carpeta alt.',
|
||||
tokenSlashHint: '/ = separador de carpeta',
|
||||
cancel: 'Cancelar',
|
||||
noTargetDir: 'Por favor, elige primero una carpeta de destino.',
|
||||
noSources: 'Por favor, selecciona al menos una fuente.',
|
||||
noTracks: 'No se encontraron pistas en las fuentes seleccionadas.',
|
||||
fetchError: 'Error al obtener pistas del servidor.',
|
||||
syncComplete: 'Sincronización completada.',
|
||||
dismiss: 'Cerrar',
|
||||
cancelSync: 'Cancelar',
|
||||
syncCancelled: 'Sincronización cancelada ({{done}} / {{total}} transferidos).',
|
||||
colName: 'Nombre',
|
||||
colType: 'Tipo',
|
||||
colStatus: 'Estado',
|
||||
onDevice: 'Gestor de dispositivo',
|
||||
addSources: 'Agregar…',
|
||||
syncResult: '{{done}} transferido(s), {{skipped}} ya actualizado(s) ({{total}} total)',
|
||||
deleteFromDevice: 'Marcar para eliminar ({{count}})',
|
||||
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
|
||||
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
|
||||
manifestImported: '{{count}} fuente(s) importada(s) desde el manifiesto del dispositivo.',
|
||||
statusSynced: 'Sincronizado',
|
||||
statusPending: 'Pendiente',
|
||||
statusDeletion: 'Eliminación',
|
||||
markForDeletion: 'Marcar para eliminar',
|
||||
removeSource: 'Eliminar',
|
||||
undoDeletion: 'Deshacer eliminación',
|
||||
syncInBackground: 'Sincronización iniciada en segundo plano — puedes navegar a otro lugar.',
|
||||
syncInProgress: '{{done}} / {{total}} pistas…',
|
||||
syncSummary: 'Resumen de sincronización',
|
||||
calculating: 'Calculando la carga útil requerida…',
|
||||
filesToAdd: 'Archivos a agregar:',
|
||||
filesToDelete: 'Archivos a eliminar:',
|
||||
netChange: 'Cambio neto:',
|
||||
availableSpace: 'Espacio disponible en disco:',
|
||||
spaceWarning: 'Advertencia: El dispositivo de destino no tiene suficiente espacio reportado.',
|
||||
proceed: 'Proceder con la sincronización',
|
||||
notEnoughSpace: '¡Espacio físico en disco insuficiente detectado!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Álbumes aleatorios',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const frTranslation = {
|
||||
expand: 'Développer la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
syncingTracks: 'Synchro {{done}}/{{total}}…',
|
||||
cancelDownload: 'Annuler le téléchargement',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
@@ -24,6 +25,7 @@ export const frTranslation = {
|
||||
mostPlayed: 'Les plus joués',
|
||||
radio: 'Radio Internet',
|
||||
folderBrowser: 'Explorateur de dossiers',
|
||||
deviceSync: 'Sync appareil',
|
||||
libraryScope: 'Portée de la bibliothèque',
|
||||
allLibraries: 'Toutes les bibliothèques',
|
||||
expandPlaylists: 'Développer les playlists',
|
||||
@@ -99,6 +101,7 @@ export const frTranslation = {
|
||||
startRadio: 'Démarrer la radio',
|
||||
instantMix: 'Mix instantané',
|
||||
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
||||
cliMixNeedsTrack: 'Aucune lecture en cours — lancez d’abord la lecture, puis relancez la commande de mix.',
|
||||
lfmLove: 'Aimer sur Last.fm',
|
||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||
favorite: 'Favori',
|
||||
@@ -151,6 +154,7 @@ export const frTranslation = {
|
||||
trackDuration: 'Durée',
|
||||
trackTotal: 'Total',
|
||||
columns: 'Colonnes',
|
||||
resetColumns: 'Réinitialiser',
|
||||
notFound: 'Album introuvable.',
|
||||
bioModal: 'Biographie de l\'artiste',
|
||||
bioClose: 'Fermer',
|
||||
@@ -209,6 +213,11 @@ export const frTranslation = {
|
||||
playAll: 'Tout lire',
|
||||
removeSong: 'Retirer des favoris',
|
||||
stations: 'Stations de radio',
|
||||
showingFiltered: 'Affichage de {{filtered}} sur {{total}} ({{artist}})',
|
||||
showingCount: 'Affichage de {{filtered}} sur {{total}}',
|
||||
clearArtistFilter: 'Effacer le filtre artiste',
|
||||
noFilterResults: 'Aucun résultat avec les filtres sélectionnés.',
|
||||
allArtists: 'Tous les artistes',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Créer un mix',
|
||||
@@ -348,6 +357,10 @@ export const frTranslation = {
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
serverSettings: 'Paramètres serveur',
|
||||
switchServerTitle: 'Changer de serveur',
|
||||
switchServerHint: 'Cliquez pour choisir un autre serveur enregistré.',
|
||||
manageServers: 'Gérer les serveurs…',
|
||||
switchFailed: 'Impossible de changer — serveur injoignable.',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
},
|
||||
@@ -375,6 +388,7 @@ export const frTranslation = {
|
||||
filterSearchGenres: 'Rechercher des genres…',
|
||||
filterNoGenres: 'Aucun genre trouvé',
|
||||
filterClear: 'Effacer',
|
||||
play: 'Lire',
|
||||
bulkSelected: '{{count}} sélectionné(s)',
|
||||
clearSelection: 'Effacer la sélection',
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
@@ -397,6 +411,10 @@ export const frTranslation = {
|
||||
durationHoursMinutes: '{{hours}} h {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
||||
filters: 'Filtres',
|
||||
more: 'plus',
|
||||
yearRange: 'Plage d\'années',
|
||||
clearAll: 'Tout effacer',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -519,6 +537,11 @@ export const frTranslation = {
|
||||
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
|
||||
discordAppleCovers: 'Récupérer les pochettes via Apple Music pour Discord',
|
||||
discordAppleCoversDesc: 'Envoie le nom de l\'artiste et de l\'album à l\'API Apple pour trouver une pochette pour votre profil Discord. Désactivé par défaut pour des raisons de confidentialité.',
|
||||
discordTemplates: 'Modèles de texte personnalisés',
|
||||
discordTemplatesDesc: 'Personnalisez les informations affichées sur votre profil Discord. Variables : {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Ligne principale (details)',
|
||||
discordTemplateState: 'Ligne secondaire (state)',
|
||||
discordTemplateLargeText: 'Info-bulle album (largeText)',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
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',
|
||||
@@ -565,6 +588,8 @@ export const frTranslation = {
|
||||
sidebarReset: 'Réinitialiser',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
randomNavSplitTitle: 'Diviser la navigation Mix',
|
||||
randomNavSplitDesc: 'Afficher "Mix Aléatoire" et "Albums Aléatoires" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
|
||||
tabInput: 'Entrée',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
@@ -658,6 +683,13 @@ export const frTranslation = {
|
||||
themeSchedulerNightTheme: 'Thème de nuit',
|
||||
themeSchedulerNightStart: 'Début de la nuit',
|
||||
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.',
|
||||
visualOptionsTitle: 'Options Visuelles',
|
||||
coverArtBackground: "Fond d'Art de Poche",
|
||||
coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists",
|
||||
playlistCoverPhoto: 'Photo de Couverture de Playlist',
|
||||
playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists',
|
||||
showBitrate: 'Afficher le Débit',
|
||||
showBitrateSub: 'Afficher le débit audio dans les listes de pistes',
|
||||
uiScaleTitle: "Mise à l'échelle de l'interface",
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -764,6 +796,32 @@ export const frTranslation = {
|
||||
a46: 'Le Hot Cache (Alpha, Paramètres → Bibliothèque) précharge les prochains morceaux de la file sur le disque pour une lecture instantanée. Psysonic conserve le morceau actuel et les 5 suivants et évince les anciens quand la limite est atteinte.',
|
||||
q47: 'Puis-je mettre en cache une playlist hors ligne ?',
|
||||
a47: 'Oui. Dans les détails d\'une playlist, cliquez sur l\'icône de téléchargement. Une fois entièrement mis en cache, l\'icône se transforme en corbeille rouge ; cliquer dessus supprime la playlist du cache hors ligne.',
|
||||
q48: 'Qu\'est-ce que la file infinie ?',
|
||||
a48: 'Lorsque la file se vide et que la répétition est désactivée, Psysonic ajoute silencieusement des pistes aléatoires pour que la lecture ne s\'arrête jamais. Les pistes ajoutées automatiquement apparaissent sous le séparateur « — Ajouté automatiquement — ». Activez/désactivez via l\'icône infini dans l\'en-tête de la file.',
|
||||
q49: 'Puis-je contrôler Psysonic en ligne de commande ?',
|
||||
a49: 'Oui. Exemples : psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one, --player star / unstar, --player rating 1-5. Aussi : changer de serveur, d\'appareil audio, filtrer la bibliothèque, lancer un mix instantané et rechercher. Aide complète : psysonic --player --help.',
|
||||
q50: 'Comment gérer les playlists ?',
|
||||
a50: 'Via « Playlists » dans la barre latérale. Créez une playlist avec « Nouvelle playlist ». Dans la vue détaillée, réorganisez les pistes par glisser-déposer, supprimez-les avec le bouton ×, ajoutez des chansons via la recherche et consultez les « Suggestions » pour des recommandations intelligentes.',
|
||||
q51: 'Puis-je sauvegarder et restaurer mes paramètres ?',
|
||||
a51: 'Oui. Paramètres → Sauvegarde & Restauration permet d\'exporter tous les paramètres, profils de serveur, thèmes et raccourcis clavier dans un fichier JSON. Importez-le sur une autre machine pour tout restaurer d\'un coup. Les mots de passe sont inclus — conservez le fichier en lieu sûr.',
|
||||
q52: 'Comment changer le périphérique de sortie audio ?',
|
||||
a52: 'Paramètres → Audio → Périphérique de sortie. Psysonic liste tous les périphériques disponibles. La sélection prend effet immédiatement. Utilisez le bouton d\'actualisation si un périphérique a été branché après le démarrage de l\'application.',
|
||||
q53: 'Qu\'est-ce que Device Sync ?',
|
||||
a53: 'Device Sync copie des albums, playlists ou artistes sur une clé USB ou une carte SD. Ouvrez-le via « Device Sync » dans la barre latérale, choisissez un dossier cible, sélectionnez vos sources et cliquez sur « Transférer vers l\'appareil ».',
|
||||
q54: 'Qu\'est-ce que le modèle de nom de fichier dans Device Sync ?',
|
||||
a54: 'Le modèle contrôle comment les pistes sont nommées et organisées. Utilisez les préréglages ou créez le vôtre avec les jetons cliquables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year} et / comme séparateur de dossier. Un aperçu en direct est affiché.',
|
||||
q55: 'Device Sync fonctionne-t-il entre plateformes ?',
|
||||
a55: 'Oui. Le manifeste écrit sur l\'appareil contient le modèle utilisé lors de la synchronisation. Sur un autre OS avec un modèle différent, Psysonic détecte correctement les fichiers existants.',
|
||||
s9: 'Device Sync',
|
||||
q56: 'Qu\'est-ce que la radio Internet ?',
|
||||
a56: 'La page Radio Internet permet de lire n\'importe quel flux en direct dans Psysonic. Les stations proviennent de votre serveur Navidrome — ajoutez-les via le panneau d\'administration ou importez un fichier .pls/.m3u. La lecture utilise l\'audio HTML5.',
|
||||
q57: 'Quels formats de flux la radio Internet supporte-t-elle ?',
|
||||
a57: 'MP3, AAC, OGG Vorbis et la plupart des flux HLS. La prise en charge des codecs dépend de la plateforme — MP3 et AAC fonctionnent partout.',
|
||||
s10: 'Radio Internet',
|
||||
q58: 'D\'où Psysonic récupère-t-il les paroles ?',
|
||||
a58: 'Trois sources configurables dans Paramètres → Paroles : votre serveur Navidrome (paroles intégrées/OpenSubsonic), LRCLIB (paroles synchronisées communautaires) et NetEase Cloud Music. Chaque source peut être activée/désactivée et priorisée par glisser-déposer.',
|
||||
q59: 'Y a-t-il une vue de ce qu\'écoutent les autres utilisateurs ?',
|
||||
a59: 'Oui. Cliquez sur l\'icône de diffusion en haut à droite pour voir ce que les autres utilisateurs écoutent actuellement sur votre serveur Navidrome, mis à jour toutes les 10 secondes.',
|
||||
},
|
||||
queue: {
|
||||
title: 'File d\'attente',
|
||||
@@ -795,6 +853,12 @@ export const frTranslation = {
|
||||
trackPlural: 'pistes',
|
||||
showRemaining: 'Afficher le temps restant',
|
||||
showTotal: 'Afficher la durée totale',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Pic {{pk}}',
|
||||
sourceOffline: 'Lecture depuis la bibliothèque hors ligne',
|
||||
sourceHot: 'Lecture depuis le cache',
|
||||
sourceStream: 'Lecture depuis le flux réseau',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiques',
|
||||
@@ -966,6 +1030,24 @@ export const frTranslation = {
|
||||
addToPlaylistError: 'Erreur lors de l\'ajout à la playlist',
|
||||
removeSuccess: 'Morceau retiré de la playlist',
|
||||
removeError: 'Erreur lors du retrait de la playlist',
|
||||
importCSV: 'Importer CSV',
|
||||
importCSVTooltip: 'Importer depuis CSV Spotify',
|
||||
csvImportReport: 'Rapport d\'Import CSV',
|
||||
csvImportTotal: 'Total',
|
||||
csvImportAdded: 'Ajoutés',
|
||||
csvImportDuplicates: 'Doublons',
|
||||
csvImportNotFound: 'Non Trouvés',
|
||||
csvImportNetworkErrors: 'Erreurs Réseau',
|
||||
csvImportDuplicatesTitle: 'Titres en Doublon (Déjà dans la Playlist):',
|
||||
csvImportNotFoundTitle: 'Titres Non Trouvés:',
|
||||
csvImportNetworkErrorsTitle: 'Erreurs Réseau (peut réessayer l\'import):',
|
||||
csvImportDownloadReport: 'Télécharger le Rapport',
|
||||
csvImportClose: 'Fermer',
|
||||
csvImportNoValidTracks: 'Aucun titre valide trouvé dans le fichier CSV',
|
||||
csvImportFailed: 'Échec de l\'import CSV',
|
||||
csvImportToast: '{{added}} ajoutés, {{notFound}} non trouvés, {{duplicates}} doublons',
|
||||
csvImportDownloadSuccess: 'Rapport téléchargé avec succès',
|
||||
csvImportDownloadError: 'Échec du téléchargement du rapport',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Les plus joués',
|
||||
@@ -1019,4 +1101,77 @@ export const frTranslation = {
|
||||
empty: 'Dossier vide',
|
||||
error: 'Échec du chargement',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Sync appareil',
|
||||
targetFolder: 'Dossier cible',
|
||||
noFolderChosen: 'Aucun dossier sélectionné',
|
||||
selectDrive: 'Sélectionner un lecteur…',
|
||||
refreshDrives: 'Actualiser les lecteurs',
|
||||
noDrivesDetected: 'Aucun lecteur amovible détecté',
|
||||
browseManual: 'Parcourir manuellement…',
|
||||
free: 'libre',
|
||||
notMountedVolume: 'La cible n\u0027est pas sur un volume monté. Le lecteur a peut-être été déconnecté.',
|
||||
chooseFolder: 'Choisir…',
|
||||
filenameTemplate: 'Modèle de nom de fichier',
|
||||
targetDevice: 'Appareil cible',
|
||||
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Supprimer les fichiers absents',
|
||||
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
|
||||
confirmCleanup: 'Supprimer {{count}} fichier(s) de l\'appareil qui ne font pas partie de la sélection actuelle. Continuer ?',
|
||||
cleanupComplete: '{{count}} fichier(s) supprimé(s) de l\'appareil.',
|
||||
selectedSources: 'Sources sélectionnées',
|
||||
noSourcesSelected: 'Aucune source sélectionnée. Cliquez sur des éléments dans le navigateur pour les ajouter.',
|
||||
clearAll: 'Tout effacer',
|
||||
tabPlaylists: 'Listes de lecture',
|
||||
tabAlbums: 'Albums',
|
||||
tabArtists: 'Artistes',
|
||||
searchPlaceholder: 'Rechercher…',
|
||||
syncButton: 'Synchroniser vers l\'appareil',
|
||||
actionTransfer: 'Transférer vers l\'appareil',
|
||||
actionDelete: 'Supprimer de l\'appareil',
|
||||
actionApplyAll: 'Appliquer toutes les modifications',
|
||||
templatePreview: 'Aperçu',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Dossier alt.',
|
||||
tokenSlashHint: '/ = séparateur de dossier',
|
||||
cancel: 'Annuler',
|
||||
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
|
||||
noSources: 'Veuillez sélectionner au moins une source.',
|
||||
noTracks: 'Aucune piste trouvée dans les sources sélectionnées.',
|
||||
fetchError: 'Échec du chargement des pistes depuis le serveur.',
|
||||
syncComplete: 'Synchronisation terminée.',
|
||||
dismiss: 'Fermer',
|
||||
cancelSync: 'Annuler',
|
||||
syncCancelled: 'Synchronisation annulée ({{done}} / {{total}} transférés).',
|
||||
colName: 'Nom',
|
||||
colType: 'Type',
|
||||
colStatus: 'Statut',
|
||||
onDevice: 'Gestionnaire d\'appareil',
|
||||
addSources: 'Ajouter…',
|
||||
syncResult: '{{done}} transféré(s), {{skipped}} déjà à jour ({{total}} au total)',
|
||||
deleteFromDevice: 'Marquer pour suppression ({{count}})',
|
||||
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
|
||||
deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.',
|
||||
manifestImported: '{{count}} source(s) importée(s) depuis le manifeste du périphérique.',
|
||||
statusSynced: 'Synchronisé',
|
||||
statusPending: 'En attente',
|
||||
statusDeletion: 'Suppression',
|
||||
markForDeletion: 'Marquer pour suppression',
|
||||
removeSource: 'Supprimer',
|
||||
undoDeletion: 'Annuler la suppression',
|
||||
syncInBackground: 'Sync démarré en arrière-plan — vous pouvez naviguer ailleurs.',
|
||||
syncInProgress: '{{done}} / {{total}} pistes…',
|
||||
syncSummary: 'Résumé de la synchronisation',
|
||||
calculating: 'Calcul de la charge utile…',
|
||||
filesToAdd: 'Fichiers à ajouter :',
|
||||
filesToDelete: 'Fichiers à supprimer :',
|
||||
netChange: 'Variation nette :',
|
||||
availableSpace: 'Espace disque disponible :',
|
||||
spaceWarning: 'Attention : L\'appareil cible ne dispose pas d\'assez d\'espace signalé.',
|
||||
proceed: 'Procéder à la synchronisation',
|
||||
notEnoughSpace: 'Espace disque physique insuffisant détecté !',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Albums aléatoires',
|
||||
},
|
||||
};
|
||||
|
||||
+156
-1
@@ -17,6 +17,7 @@ export const nbTranslation = {
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
syncingTracks: 'Synkroniserer {{done}}/{{total}}…',
|
||||
cancelDownload: 'Avbryt nedlasting',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
@@ -24,6 +25,7 @@ export const nbTranslation = {
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
folderBrowser: 'Mappeleser',
|
||||
deviceSync: 'Enhetssynk',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
expandPlaylists: 'Utvid spillelister',
|
||||
@@ -99,6 +101,7 @@ export const nbTranslation = {
|
||||
startRadio: 'Start radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
||||
cliMixNeedsTrack: 'Ingenting spilles — start avspilling først, og kjør mix-kommandoen på nytt.',
|
||||
lfmLove: 'Lik på Last.fm',
|
||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||
favorite: 'Favoritt',
|
||||
@@ -151,6 +154,7 @@ export const nbTranslation = {
|
||||
trackDuration: 'Varighet',
|
||||
trackTotal: 'Totalt',
|
||||
columns: 'Kolonner',
|
||||
resetColumns: 'Tilbakestill',
|
||||
notFound: 'Album ble ikke funnet.',
|
||||
bioModal: 'Artistbiografi',
|
||||
bioClose: 'Lukk',
|
||||
@@ -208,7 +212,12 @@ export const nbTranslation = {
|
||||
enqueueAll: 'Legg alle i kø',
|
||||
playAll: 'Spill alle',
|
||||
removeSong: 'Fjern fra favoritter',
|
||||
stations: 'Radiostasjoner',
|
||||
stations: 'Radiostasjoner',
|
||||
showingFiltered: 'Viser {{filtered}} av {{total}} ({{artist}})',
|
||||
showingCount: 'Viser {{filtered}} av {{total}}',
|
||||
clearArtistFilter: 'Tøm artistfilter',
|
||||
noFilterResults: 'Ingen resultater med valgte filtre.',
|
||||
allArtists: 'Alle artister',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Lag en miks',
|
||||
@@ -348,6 +357,10 @@ export const nbTranslation = {
|
||||
offlineFilterArtists: 'Diskografier',
|
||||
retry: 'Prøv igjen',
|
||||
serverSettings: 'Serverinnstillinger',
|
||||
switchServerTitle: 'Bytt server',
|
||||
switchServerHint: 'Klikk for å velge en annen lagret server.',
|
||||
manageServers: 'Administrer servere…',
|
||||
switchFailed: 'Klarte ikke å bytte — serveren er utilgjengelig.',
|
||||
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
|
||||
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
|
||||
},
|
||||
@@ -375,6 +388,7 @@ export const nbTranslation = {
|
||||
filterSearchGenres: 'Søk i sjangre…',
|
||||
filterNoGenres: 'Ingen sjangre samsvarer',
|
||||
filterClear: 'Tøm',
|
||||
play: 'Spill',
|
||||
bulkSelected: '{{count}} valgt',
|
||||
clearSelection: 'Fjern utvalg',
|
||||
bulkAddToPlaylist: 'Legg til i spilleliste',
|
||||
@@ -397,6 +411,10 @@ export const nbTranslation = {
|
||||
durationHoursMinutes: '{{hours}} t {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Åpne på GitHub',
|
||||
filters: 'Filter',
|
||||
more: 'mer',
|
||||
yearRange: 'Årsspenn',
|
||||
clearAll: 'Tøm alt',
|
||||
},
|
||||
settings: {
|
||||
title: 'Innstillinger',
|
||||
@@ -518,6 +536,11 @@ export const nbTranslation = {
|
||||
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
|
||||
discordAppleCovers: 'Hent covere fra Apple Music til Discord',
|
||||
discordAppleCoversDesc: 'Sender artist- og albumnavn til Apples søke-API for å finne cover til Discord-profilen din. Deaktivert som standard av personvernhensyn.',
|
||||
discordTemplates: 'Egendefinerte tekstmaler',
|
||||
discordTemplatesDesc: 'Tilpass hvilken informasjon som vises på Discord-profilen din. Variabler: {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Primær linje (details)',
|
||||
discordTemplateState: 'Sekundær linje (state)',
|
||||
discordTemplateLargeText: 'Album-verktøytips (largeText)',
|
||||
nowPlayingEnabled: 'Vis i "Nå spiller"',
|
||||
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
|
||||
lyricsServerFirst: 'Foretrekk server-sangtekst',
|
||||
@@ -564,6 +587,8 @@ export const nbTranslation = {
|
||||
sidebarReset: 'Tilbakestill til standard',
|
||||
sidebarDrag: 'Dra for å endre rekkefølge',
|
||||
sidebarFixed: 'Alltid synlig',
|
||||
randomNavSplitTitle: 'Del Mix-navigasjon',
|
||||
randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
|
||||
tabShortcuts: 'Snarveier',
|
||||
tabServer: 'Tjener',
|
||||
tabSystem: 'System',
|
||||
@@ -657,6 +682,13 @@ export const nbTranslation = {
|
||||
themeSchedulerNightTheme: 'Natttema',
|
||||
themeSchedulerNightStart: 'Natt starter kl.',
|
||||
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
|
||||
visualOptionsTitle: 'Visuelle Alternativer',
|
||||
coverArtBackground: 'Cover-bakgrunn',
|
||||
coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter',
|
||||
playlistCoverPhoto: 'Playlist-coverfoto',
|
||||
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
|
||||
showBitrate: 'Vis Bitrate',
|
||||
showBitrateSub: 'Vis audio-bitrate i sporlister',
|
||||
uiScaleTitle: 'Grensesnittskala',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -763,6 +795,32 @@ export const nbTranslation = {
|
||||
a46: 'Hurtigbuffer (Alpha, Innstillinger → Bibliotek) forhåndslaster de neste sporene i køen til disk for umiddelbar avspilling. Psysonic beholder gjeldende spor og de neste 5 og fjerner eldre innslag når grensen nås.',
|
||||
q47: 'Kan jeg mellomlagre en spilleliste frakoblet?',
|
||||
a47: 'Ja. Åpne spillelistedetaljene og klikk på nedlastingsikonen. Når alle spor er mellomlagret, endres ikonet til en rød søppelbøtte; klikk på den for å fjerne spillelisten fra frakoblet hurtigbuffer.',
|
||||
q48: 'Hva er Infinite Queue?',
|
||||
a48: 'Når køen tømmes og gjentakelse er deaktivert, legger Psysonic automatisk til tilfeldige spor så avspillingen aldri stopper. Automatisk tillagte spor vises under skillelinjen "— Lagt til automatisk —".',
|
||||
q49: 'Kan jeg styre Psysonic fra kommandolinjen?',
|
||||
a49: 'Ja. Eksempler: psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one. Også: bytt server, lydenhet, filtrer bibliotek og søk. Full hjelp: psysonic --player --help.',
|
||||
q50: 'Hvordan administrerer jeg spillelister?',
|
||||
a50: 'Via "Spillelister" i sidefeltet. Opprett en med "Ny spilleliste". I detaljvisningen kan du dra spor for å sortere, fjerne dem med ×, legge til sanger via søk og bruke "Forslag" for smarte anbefalinger.',
|
||||
q51: 'Kan jeg sikkerhetskopiere og gjenopprette innstillingene mine?',
|
||||
a51: 'Ja. Innstillinger → Sikkerhetskopi og gjenoppretting eksporterer alle innstillinger, serverprofiler, temaer og snarveier til en JSON-fil.',
|
||||
q52: 'Hvordan endrer jeg lydutgangsenheten?',
|
||||
a52: 'Innstillinger → Lyd → Utgangsenhet. Psysonic viser alle tilgjengelige utganger. Valget trer i kraft umiddelbart.',
|
||||
q53: 'Hva er Device Sync?',
|
||||
a53: 'Device Sync kopierer album, spillelister eller artister til en USB-pinne eller SD-kort. Åpne via "Device Sync" i sidefeltet, velg en målmappe, velg kilder og klikk "Overfør til enhet".',
|
||||
q54: 'Hva er filnavnmalen i Device Sync?',
|
||||
a54: 'Malen styrer hvordan spor navngis og organiseres. Bruk forhåndsinnstillinger eller bygg din egen med klikkbare tokens: {artist}, {album}, {title}, {track_number}, {disc_number}, {year} og / som mappeskiller.',
|
||||
q55: 'Fungerer Device Sync på tvers av plattformer?',
|
||||
a55: 'Ja. Manifestet på enheten inneholder malen som ble brukt. På et annet OS gjenkjenner Psysonic eksisterende filer korrekt.',
|
||||
s9: 'Device Sync',
|
||||
q56: 'Hva er internettradio?',
|
||||
a56: 'Internettradiosiden lar deg spille av direkte strømmer i Psysonic. Stasjoner hentes fra Navidrome-serveren din. Avspilling bruker HTML5-lyd.',
|
||||
q57: 'Hvilke strømformater støtter internettradio?',
|
||||
a57: 'MP3, AAC, OGG Vorbis og de fleste HLS-strømmer. MP3 og AAC fungerer på alle plattformer.',
|
||||
s10: 'Internettradio',
|
||||
q58: 'Hvor henter Psysonic sangtekster fra?',
|
||||
a58: 'Tre konfigurerbare kilder i Innstillinger → Sangtekster: Navidrome-serveren din, LRCLIB og NetEase Cloud Music. Hver kilde kan aktiveres/deaktiveres og prioriteres ved dra-og-slipp.',
|
||||
q59: 'Finnes det en visning av hva andre brukere hører på?',
|
||||
a59: 'Ja. Klikk på kringkastingsikonet øverst til høyre for å se hva andre brukere på Navidrome-serveren din hører på, oppdatert hvert 10. sekund.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Kø',
|
||||
@@ -794,6 +852,12 @@ export const nbTranslation = {
|
||||
trackPlural: 'spor',
|
||||
showRemaining: 'Vis gjenværende tid',
|
||||
showTotal: 'Vis total tid',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Topp {{pk}}',
|
||||
sourceOffline: 'Spiller fra offlinebibliotek',
|
||||
sourceHot: 'Spiller fra cache',
|
||||
sourceStream: 'Spiller fra nettverksstrøm',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistikk',
|
||||
@@ -965,6 +1029,24 @@ export const nbTranslation = {
|
||||
addToPlaylistError: 'Feil ved å legge til i spilleliste',
|
||||
removeSuccess: 'Sang fjernet fra spilleliste',
|
||||
removeError: 'Feil ved fjerning fra spilleliste',
|
||||
importCSV: 'Importer CSV',
|
||||
importCSVTooltip: 'Importer fra Spotify CSV',
|
||||
csvImportReport: 'CSV-importrapport',
|
||||
csvImportTotal: 'Totalt',
|
||||
csvImportAdded: 'Lagt til',
|
||||
csvImportDuplicates: 'Duplikater',
|
||||
csvImportNotFound: 'Ikke funnet',
|
||||
csvImportNetworkErrors: 'Nettverksfeil',
|
||||
csvImportDuplicatesTitle: 'Duplikater (allerede i spillelisten):',
|
||||
csvImportNotFoundTitle: 'Ikke funnet spor:',
|
||||
csvImportNetworkErrorsTitle: 'Nettverksfeil (kan prøve import på nytt):',
|
||||
csvImportDownloadReport: 'Last ned rapport',
|
||||
csvImportClose: 'Lukk',
|
||||
csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen',
|
||||
csvImportFailed: 'Kunne ikke importere CSV-fil',
|
||||
csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater',
|
||||
csvImportDownloadSuccess: 'Rapport lastet ned',
|
||||
csvImportDownloadError: 'Kunne ikke laste ned rapport',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Mest spilt',
|
||||
@@ -1018,4 +1100,77 @@ export const nbTranslation = {
|
||||
empty: 'Tom mappe',
|
||||
error: 'Kunne ikke laste',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Enhetssynk',
|
||||
targetFolder: 'Målmappe',
|
||||
noFolderChosen: 'Ingen mappe valgt',
|
||||
selectDrive: 'Velg en stasjon…',
|
||||
refreshDrives: 'Oppdater stasjoner',
|
||||
noDrivesDetected: 'Ingen flyttbare stasjoner oppdaget',
|
||||
browseManual: 'Bla manuelt…',
|
||||
free: 'ledig',
|
||||
notMountedVolume: 'Målet er ikke på en montert stasjon. Enheten kan ha blitt koblet fra.',
|
||||
chooseFolder: 'Velg…',
|
||||
filenameTemplate: 'Filnavnmal',
|
||||
targetDevice: 'Målenhet',
|
||||
templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Fjern filer som ikke er i utvalget',
|
||||
cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.',
|
||||
confirmCleanup: 'Slette {{count}} fil(er) fra enheten som ikke er i det gjeldende utvalget. Fortsette?',
|
||||
cleanupComplete: '{{count}} fil(er) fjernet fra enheten.',
|
||||
selectedSources: 'Valgte kilder',
|
||||
noSourcesSelected: 'Ingen kilder valgt. Klikk på elementer i nettleseren for å legge dem til.',
|
||||
clearAll: 'Fjern alle',
|
||||
tabPlaylists: 'Spillelister',
|
||||
tabAlbums: 'Album',
|
||||
tabArtists: 'Artister',
|
||||
searchPlaceholder: 'Søk…',
|
||||
syncButton: 'Synkroniser til enhet',
|
||||
actionTransfer: 'Overfør til enhet',
|
||||
actionDelete: 'Slett fra enhet',
|
||||
actionApplyAll: 'Bruk alle endringer',
|
||||
templatePreview: 'Forhåndsvisning',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. mappe',
|
||||
tokenSlashHint: '/ = mappeskiller',
|
||||
cancel: 'Avbryt',
|
||||
noTargetDir: 'Velg en målmappe først.',
|
||||
noSources: 'Velg minst én kilde.',
|
||||
noTracks: 'Ingen spor funnet i de valgte kildene.',
|
||||
fetchError: 'Kunne ikke hente spor fra serveren.',
|
||||
syncComplete: 'Synkronisering fullført.',
|
||||
dismiss: 'Lukk',
|
||||
cancelSync: 'Avbryt',
|
||||
syncCancelled: 'Synkronisering avbrutt ({{done}} / {{total}} overført).',
|
||||
colName: 'Navn',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
onDevice: 'Enhetsbehandling',
|
||||
addSources: 'Legg til…',
|
||||
syncResult: '{{done}} overført, {{skipped}} allerede oppdatert ({{total}} totalt)',
|
||||
deleteFromDevice: 'Merk for sletting ({{count}})',
|
||||
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
|
||||
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
|
||||
manifestImported: '{{count}} kilde(r) importert fra enhetsmanifest.',
|
||||
statusSynced: 'Synkronisert',
|
||||
statusPending: 'Venter',
|
||||
statusDeletion: 'Sletting',
|
||||
markForDeletion: 'Merk for sletting',
|
||||
removeSource: 'Fjern',
|
||||
undoDeletion: 'Angre sletting',
|
||||
syncInBackground: 'Synkronisering startet i bakgrunnen — du kan navigere bort.',
|
||||
syncInProgress: '{{done}} / {{total}} spor…',
|
||||
syncSummary: 'Synkroniseringssammendrag',
|
||||
calculating: 'Beregner nødvendig nyttelast…',
|
||||
filesToAdd: 'Filer som skal legges til:',
|
||||
filesToDelete: 'Filer som skal slettes:',
|
||||
netChange: 'Nettoendring:',
|
||||
availableSpace: 'Tilgjengelig diskplass:',
|
||||
spaceWarning: 'Advarsel: Målenheten har ikke nok rapportert plass.',
|
||||
proceed: 'Fortsett med synkronisering',
|
||||
notEnoughSpace: 'Ikke nok fysisk diskplass oppdaget!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Tilfeldige album',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const nlTranslation = {
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
syncingTracks: 'Synchroniseren {{done}}/{{total}}…',
|
||||
cancelDownload: 'Download annuleren',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
@@ -24,6 +25,7 @@ export const nlTranslation = {
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Mappenverkenner',
|
||||
deviceSync: 'Apparaatsync',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
expandPlaylists: 'Afspeellijsten uitklappen',
|
||||
@@ -99,6 +101,7 @@ export const nlTranslation = {
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
||||
cliMixNeedsTrack: 'Er speelt niets — start eerst afspelen en voer het mix-commando opnieuw uit.',
|
||||
lfmLove: 'Liken op Last.fm',
|
||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||
favorite: 'Favoriet',
|
||||
@@ -150,6 +153,7 @@ export const nlTranslation = {
|
||||
trackDuration: 'Duur',
|
||||
trackTotal: 'Totaal',
|
||||
columns: 'Kolommen',
|
||||
resetColumns: 'Standaard herstellen',
|
||||
notFound: 'Album niet gevonden.',
|
||||
bioModal: 'Artiest biografie',
|
||||
bioClose: 'Sluiten',
|
||||
@@ -208,6 +212,11 @@ export const nlTranslation = {
|
||||
playAll: 'Alles afspelen',
|
||||
removeSong: 'Verwijderen uit favorieten',
|
||||
stations: 'Radiostations',
|
||||
showingFiltered: 'Toont {{filtered}} van {{total}} ({{artist}})',
|
||||
showingCount: 'Toont {{filtered}} van {{total}}',
|
||||
clearArtistFilter: 'Artiestfilter wissen',
|
||||
noFilterResults: 'Geen resultaten met de geselecteerde filters.',
|
||||
allArtists: 'Alle artiesten',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Mix samenstellen',
|
||||
@@ -347,6 +356,10 @@ export const nlTranslation = {
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
serverSettings: 'Serverinstellingen',
|
||||
switchServerTitle: 'Server wisselen',
|
||||
switchServerHint: 'Klik om een andere opgeslagen server te kiezen.',
|
||||
manageServers: 'Servers beheren…',
|
||||
switchFailed: 'Wisselen mislukt — server niet bereikbaar.',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
},
|
||||
@@ -374,6 +387,7 @@ export const nlTranslation = {
|
||||
filterSearchGenres: 'Genres zoeken…',
|
||||
filterNoGenres: 'Geen genres gevonden',
|
||||
filterClear: 'Wissen',
|
||||
play: 'Afspelen',
|
||||
bulkSelected: '{{count}} geselecteerd',
|
||||
clearSelection: 'Selectie wissen',
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
@@ -396,6 +410,10 @@ export const nlTranslation = {
|
||||
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Openen op GitHub',
|
||||
filters: 'Filters',
|
||||
more: 'meer',
|
||||
yearRange: 'Jaarbereik',
|
||||
clearAll: 'Alles wissen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -518,6 +536,11 @@ export const nlTranslation = {
|
||||
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
|
||||
discordAppleCovers: 'Hoezen ophalen via Apple Music voor Discord',
|
||||
discordAppleCoversDesc: 'Stuurt artiest- en albumnaam naar de Apple-zoek-API om een hoes te vinden voor je Discord-profiel. Standaard uitgeschakeld vanwege privacy.',
|
||||
discordTemplates: 'Aangepaste tekstsjablonen',
|
||||
discordTemplatesDesc: 'Pas aan welke informatie wordt weergegeven op je Discord-profiel. Variabelen: {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Primaire regel (details)',
|
||||
discordTemplateState: 'Secundaire regel (state)',
|
||||
discordTemplateLargeText: 'Album-tooltip (largeText)',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
lyricsServerFirst: 'Server-songtekst voorrang geven',
|
||||
@@ -564,6 +587,8 @@ export const nlTranslation = {
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
randomNavSplitTitle: 'Mix-navigatie splitsen',
|
||||
randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
|
||||
tabInput: 'Invoer',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
@@ -657,6 +682,13 @@ export const nlTranslation = {
|
||||
themeSchedulerNightTheme: 'Nachtthema',
|
||||
themeSchedulerNightStart: 'Nacht begint om',
|
||||
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.",
|
||||
visualOptionsTitle: 'Visuele Opties',
|
||||
coverArtBackground: 'Cover Achtergrond',
|
||||
coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers',
|
||||
playlistCoverPhoto: 'Playlist Coverfoto',
|
||||
playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave',
|
||||
showBitrate: 'Toon Bitrate',
|
||||
showBitrateSub: 'Toon audio bitrate in tracklijsten',
|
||||
uiScaleTitle: 'Interface schaal',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
@@ -763,6 +795,32 @@ export const nlTranslation = {
|
||||
a46: 'De Hot Cache (Alpha, Instellingen → Bibliotheek) laadt de volgende nummers in de wachtrij vooraf naar de schijf voor directe weergave. Psysonic bewaart het huidige nummer en de volgende 5 en verwijdert oudere items wanneer de limiet is bereikt.',
|
||||
q47: 'Kan ik een afspeellijst offline cachen?',
|
||||
a47: 'Ja. Open de afspeellijstdetails en klik op het downloadpictogram. Zodra alle tracks zijn gecached, verandert het pictogram in een rode prullenbak; klik erop om de afspeellijst uit de offlinecache te verwijderen.',
|
||||
q48: 'Wat is de Infinite Queue?',
|
||||
a48: 'Als de wachtrij leeg raakt en herhalen uitgeschakeld is, voegt Psysonic automatisch willekeurige nummers toe zodat het afspelen nooit stopt. Automatisch toegevoegde nummers verschijnen onder het scheidingsteken "— Automatisch toegevoegd —". Schakel in/uit via het oneindig-pictogram in de wachtrij-header.',
|
||||
q49: 'Kan ik Psysonic via de opdrachtregel bedienen?',
|
||||
a49: 'Ja. Voorbeelden: psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one. Ook: van server wisselen, audioapparaat wijzigen, bibliotheek filteren en zoeken. Volledige hulp: psysonic --player --help.',
|
||||
q50: 'Hoe beheer ik afspeellijsten?',
|
||||
a50: 'Via "Afspeellijsten" in de zijbalk. Maak een afspeellijst aan met "Nieuwe afspeellijst". In de detailweergave kunt u nummers slepen om te herordenen, verwijderen met ×, nieuwe toevoegen via zoeken en "Suggesties" gebruiken voor slimme aanbevelingen.',
|
||||
q51: 'Kan ik mijn instellingen back-uppen en herstellen?',
|
||||
a51: 'Ja. Instellingen → Back-up & Herstel exporteert alle instellingen, serverprofielen, thema\'s en snelkoppelingen naar een JSON-bestand. Importeer het op een ander apparaat om alles in één keer te herstellen.',
|
||||
q52: 'Hoe wijzig ik het audio-uitvoerapparaat?',
|
||||
a52: 'Instellingen → Audio → Uitvoerapparaat. Psysonic toont alle beschikbare uitgangen. De selectie wordt direct van kracht. Gebruik de verversknop als een apparaat na het starten van de app is aangesloten.',
|
||||
q53: 'Wat is Device Sync?',
|
||||
a53: 'Device Sync kopieert albums, afspeellijsten of artiesten naar een USB-stick of SD-kaart. Open het via "Device Sync" in de zijbalk, kies een doelmap, selecteer bronnen en klik op "Naar apparaat overbrengen".',
|
||||
q54: 'Wat is de bestandsnaamsjabloon in Device Sync?',
|
||||
a54: 'Het sjabloon bepaalt hoe nummers worden benoemd en georganiseerd. Gebruik de voorinstellingen of bouw uw eigen sjabloon met de klikbare tokens: {artist}, {album}, {title}, {track_number}, {disc_number}, {year} en / als mapscheidingsteken.',
|
||||
q55: 'Werkt Device Sync platformoverschrijdend?',
|
||||
a55: 'Ja. Het manifest op het apparaat bevat het gebruikte sjabloon. Op een ander besturingssysteem herkent Psysonic bestaande bestanden correct, ook bij een ander sjabloon.',
|
||||
s9: 'Device Sync',
|
||||
q56: 'Wat is internetradio?',
|
||||
a56: 'De internetradiopagina speelt live streams af in Psysonic. Stations komen van uw Navidrome-server — voeg ze toe via het beheerpaneel of importeer een .pls/.m3u-bestand. Afspelen gebruikt HTML5-audio.',
|
||||
q57: 'Welke streamformaten ondersteunt internetradio?',
|
||||
a57: 'MP3, AAC, OGG Vorbis en de meeste HLS-streams. MP3 en AAC werken op alle platforms.',
|
||||
s10: 'Internetradio',
|
||||
q58: 'Waar haalt Psysonic songteksten vandaan?',
|
||||
a58: 'Drie configureerbare bronnen in Instellingen → Songteksten: uw Navidrome-server, LRCLIB en NetEase Cloud Music. Elke bron kan worden in-/uitgeschakeld en geprioriteerd door te slepen.',
|
||||
q59: 'Is er een weergave van wat andere gebruikers luisteren?',
|
||||
a59: 'Ja. Klik op het uitzendpictogram rechtsboven om te zien wat andere gebruikers op uw Navidrome-server beluisteren, elke 10 seconden bijgewerkt.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Wachtrij',
|
||||
@@ -794,6 +852,12 @@ export const nlTranslation = {
|
||||
trackPlural: 'nummers',
|
||||
showRemaining: 'Resterende tijd tonen',
|
||||
showTotal: 'Totale tijd tonen',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Piek {{pk}}',
|
||||
sourceOffline: 'Afspelen vanuit offlinebibliotheek',
|
||||
sourceHot: 'Afspelen vanuit cache',
|
||||
sourceStream: 'Afspelen vanuit netwerkstream',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistieken',
|
||||
@@ -963,6 +1027,26 @@ export const nlTranslation = {
|
||||
addToPlaylistSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
|
||||
addToPlaylistNoNew: 'Geen nieuwe nummers voor {{playlist}}',
|
||||
addToPlaylistError: 'Fout bij toevoegen aan playlist',
|
||||
removeSuccess: 'Nummer verwijderd van playlist',
|
||||
removeError: 'Fout bij verwijderen uit playlist',
|
||||
importCSV: 'CSV Importeren',
|
||||
importCSVTooltip: 'Importeren vanuit Spotify CSV',
|
||||
csvImportReport: 'CSV-importrapport',
|
||||
csvImportTotal: 'Totaal',
|
||||
csvImportAdded: 'Toegevoegd',
|
||||
csvImportDuplicates: 'Duplicaten',
|
||||
csvImportNotFound: 'Niet Gevonden',
|
||||
csvImportNetworkErrors: 'Netwerkfouten',
|
||||
csvImportDuplicatesTitle: 'Dubbele nummers (al in playlist):',
|
||||
csvImportNotFoundTitle: 'Niet gevonden nummers:',
|
||||
csvImportNetworkErrorsTitle: 'Netwerkfouten (import kan opnieuw proberen):',
|
||||
csvImportDownloadReport: 'Rapport downloaden',
|
||||
csvImportClose: 'Sluiten',
|
||||
csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand',
|
||||
csvImportFailed: 'CSV-import mislukt',
|
||||
csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten',
|
||||
csvImportDownloadSuccess: 'Rapport succesvol gedownload',
|
||||
csvImportDownloadError: 'Rapport downloaden mislukt',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meest gespeeld',
|
||||
@@ -1016,4 +1100,77 @@ export const nlTranslation = {
|
||||
empty: 'Lege map',
|
||||
error: 'Laden mislukt',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Apparaatsync',
|
||||
targetFolder: 'Doelmap',
|
||||
noFolderChosen: 'Geen map gekozen',
|
||||
selectDrive: 'Selecteer een schijf…',
|
||||
refreshDrives: 'Schijven vernieuwen',
|
||||
noDrivesDetected: 'Geen verwijderbare schijven gedetecteerd',
|
||||
browseManual: 'Handmatig bladeren…',
|
||||
free: 'vrij',
|
||||
notMountedVolume: 'Het doel bevindt zich niet op een gekoppeld volume. De schijf is mogelijk losgekoppeld.',
|
||||
chooseFolder: 'Kiezen…',
|
||||
filenameTemplate: 'Bestandsnaamsjabloon',
|
||||
targetDevice: 'Doelapparaat',
|
||||
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
|
||||
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
|
||||
confirmCleanup: '{{count}} bestand(en) verwijderen van het apparaat die niet in de huidige selectie staan. Doorgaan?',
|
||||
cleanupComplete: '{{count}} bestand(en) van het apparaat verwijderd.',
|
||||
selectedSources: 'Geselecteerde bronnen',
|
||||
noSourcesSelected: 'Geen bronnen geselecteerd. Klik op items in de browser om ze toe te voegen.',
|
||||
clearAll: 'Alles wissen',
|
||||
tabPlaylists: 'Afspeellijsten',
|
||||
tabAlbums: 'Albums',
|
||||
tabArtists: 'Artiesten',
|
||||
searchPlaceholder: 'Zoeken…',
|
||||
syncButton: 'Synchroniseren naar apparaat',
|
||||
actionTransfer: 'Overdragen naar apparaat',
|
||||
actionDelete: 'Verwijderen van apparaat',
|
||||
actionApplyAll: 'Alle wijzigingen toepassen',
|
||||
templatePreview: 'Voorbeeld',
|
||||
templatePresetStandard: 'Standaard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. map',
|
||||
tokenSlashHint: '/ = mapscheidingsteken',
|
||||
cancel: 'Annuleren',
|
||||
noTargetDir: 'Kies eerst een doelmap.',
|
||||
noSources: 'Selecteer minimaal één bron.',
|
||||
noTracks: 'Geen nummers gevonden in de geselecteerde bronnen.',
|
||||
fetchError: 'Ophalen van nummers van de server mislukt.',
|
||||
syncComplete: 'Synchronisatie voltooid.',
|
||||
dismiss: 'Sluiten',
|
||||
cancelSync: 'Annuleren',
|
||||
syncCancelled: 'Synchronisatie geannuleerd ({{done}} / {{total}} overgebracht).',
|
||||
colName: 'Naam',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
onDevice: 'Apparaatbeheer',
|
||||
addSources: 'Toevoegen…',
|
||||
syncResult: '{{done}} overgedragen, {{skipped}} al up-to-date ({{total}} totaal)',
|
||||
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
|
||||
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
|
||||
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
|
||||
manifestImported: '{{count}} bron(nen) geïmporteerd uit apparaatmanifest.',
|
||||
statusSynced: 'Gesynchroniseerd',
|
||||
statusPending: 'In behandeling',
|
||||
statusDeletion: 'Verwijdering',
|
||||
markForDeletion: 'Markeren voor verwijdering',
|
||||
removeSource: 'Verwijderen',
|
||||
undoDeletion: 'Verwijdering ongedaan maken',
|
||||
syncInBackground: 'Sync gestart op achtergrond — u kunt navigeren.',
|
||||
syncInProgress: '{{done}} / {{total}} nummers…',
|
||||
syncSummary: 'Synchronisatieoverzicht',
|
||||
calculating: 'Vereiste payload berekenen…',
|
||||
filesToAdd: 'Te toevoegen bestanden:',
|
||||
filesToDelete: 'Te verwijderen bestanden:',
|
||||
netChange: 'Nettoverandering:',
|
||||
availableSpace: 'Beschikbare schijfruimte:',
|
||||
spaceWarning: 'Waarschuwing: Het doelapparaat heeft niet genoeg gerapporteerde ruimte.',
|
||||
proceed: 'Doorgaan met synchronisatie',
|
||||
notEnoughSpace: 'Onvoldoende fysieke schijfruimte gedetecteerd!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Willekeurige albums',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ruTranslation = {
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
syncingTracks: 'Синхронизация {{done}}/{{total}}…',
|
||||
cancelDownload: 'Отменить загрузку',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
@@ -25,6 +26,7 @@ export const ruTranslation = {
|
||||
mostPlayed: 'Популярное',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
deviceSync: 'Синхронизация устройства',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
@@ -100,6 +102,7 @@ export const ruTranslation = {
|
||||
startRadio: 'Радио по похожим',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||
cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
@@ -153,6 +156,7 @@ export const ruTranslation = {
|
||||
trackDuration: 'Длительность',
|
||||
trackTotal: 'Итого',
|
||||
columns: 'Колонки',
|
||||
resetColumns: 'Сбросить',
|
||||
notFound: 'Альбом не найден.',
|
||||
bioModal: 'Биография исполнителя',
|
||||
bioClose: 'Закрыть',
|
||||
@@ -213,6 +217,11 @@ export const ruTranslation = {
|
||||
playAll: 'Воспроизвести всё',
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
|
||||
showingCount: 'Показано {{filtered}} из {{total}}',
|
||||
clearArtistFilter: 'Сбросить фильтр исполнителя',
|
||||
noFilterResults: 'Нет результатов с выбранными фильтрами.',
|
||||
allArtists: 'Все исполнители',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Собрать микс',
|
||||
@@ -362,6 +371,10 @@ export const ruTranslation = {
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
serverSettings: 'Настройки сервера',
|
||||
switchServerTitle: 'Сменить сервер',
|
||||
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
||||
manageServers: 'Управление серверами…',
|
||||
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
||||
lastfmConnected: 'Last.fm: @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||
},
|
||||
@@ -389,6 +402,7 @@ export const ruTranslation = {
|
||||
filterSearchGenres: 'Поиск жанров…',
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
play: 'Воспроизвести',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
clearSelection: 'Сбросить выбор',
|
||||
bulkAddToPlaylist: 'В плейлист',
|
||||
@@ -411,6 +425,10 @@ export const ruTranslation = {
|
||||
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
||||
durationMinutesOnly: '{{minutes}}мин',
|
||||
updaterOpenGitHub: 'Открыть на GitHub',
|
||||
filters: 'Фильтры',
|
||||
more: 'еще',
|
||||
yearRange: 'Диапазон лет',
|
||||
clearAll: 'Очистить всё',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
@@ -541,6 +559,11 @@ export const ruTranslation = {
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в Apple Music API для поиска обложки для профиля Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
discordTemplates: 'Пользовательские шаблоны текста',
|
||||
discordTemplatesDesc: 'Настройте, какая информация отображается в профиле Discord. Переменные: {title}, {artist}, {album}',
|
||||
discordTemplateDetails: 'Основная строка (details)',
|
||||
discordTemplateState: 'Вторичная строка (state)',
|
||||
discordTemplateLargeText: 'Подсказка альбома (largeText)',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
@@ -590,6 +613,8 @@ export const ruTranslation = {
|
||||
sidebarReset: 'Сбросить порядок',
|
||||
sidebarDrag: 'Перетащите для порядка',
|
||||
sidebarFixed: 'Всегда показывать',
|
||||
randomNavSplitTitle: 'Разделить навигацию микса',
|
||||
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
|
||||
tabInput: 'Ввод',
|
||||
tabServer: 'Сервер',
|
||||
tabSystem: 'Система',
|
||||
@@ -685,6 +710,13 @@ export const ruTranslation = {
|
||||
themeSchedulerNightTheme: 'Ночная тема',
|
||||
themeSchedulerNightStart: 'Ночь начинается в',
|
||||
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
|
||||
visualOptionsTitle: 'Визуальные Настройки',
|
||||
coverArtBackground: 'Фон Обложки',
|
||||
coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
|
||||
playlistCoverPhoto: 'Обложка Плейлиста',
|
||||
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
|
||||
showBitrate: 'Показывать Битрейт',
|
||||
showBitrateSub: 'Отображать битрейт аудио в списках треков',
|
||||
uiScaleTitle: 'Масштаб интерфейса',
|
||||
uiScaleLabel: 'Масштаб',
|
||||
},
|
||||
@@ -814,6 +846,32 @@ export const ruTranslation = {
|
||||
a46: 'Горячий кэш (Alpha, Настройки → Библиотека) предзагружает следующие треки очереди на диск для мгновенного воспроизведения. Psysonic хранит текущий трек и следующие 5, вытесняя старые записи при достижении лимита.',
|
||||
q47: 'Можно ли кэшировать плейлист для офлайн?',
|
||||
a47: 'Да. Откройте детали плейлиста и нажмите иконку загрузки. После полного кэширования иконка сменяется на красную корзину; нажмите её, чтобы удалить плейлист из офлайн-кэша.',
|
||||
q48: 'Что такое бесконечная очередь?',
|
||||
a48: 'Когда очередь заканчивается и повтор отключён, Psysonic автоматически добавляет случайные треки, чтобы воспроизведение не прерывалось. Автодобавленные треки отображаются под разделителем «— Добавлено автоматически —».',
|
||||
q49: 'Можно ли управлять Psysonic через командную строку?',
|
||||
a49: 'Да. Примеры: psysonic --player play / pause / next / prev, --player volume 75, --player seek 15, --player mute, --player shuffle, --player repeat off|all|one. Также: переключение сервера, аудиоустройства, фильтрация библиотеки и поиск. Полная справка: psysonic --player --help.',
|
||||
q50: 'Как управлять плейлистами?',
|
||||
a50: 'Через «Плейлисты» на боковой панели. Создайте новый плейлист кнопкой «Новый плейлист». В детальном представлении перетаскивайте треки для сортировки, удаляйте кнопкой ×, добавляйте через поиск и используйте «Предложения» для умных рекомендаций.',
|
||||
q51: 'Можно ли создать резервную копию настроек?',
|
||||
a51: 'Да. Настройки → Резервная копия и восстановление экспортирует все настройки, профили серверов, темы и горячие клавиши в JSON-файл.',
|
||||
q52: 'Как изменить устройство вывода звука?',
|
||||
a52: 'Настройки → Аудио → Устройство вывода. Psysonic показывает все доступные выходы. Выбор вступает в силу немедленно.',
|
||||
q53: 'Что такое синхронизация с устройством?',
|
||||
a53: 'Device Sync копирует альбомы, плейлисты или исполнителей на USB-накопитель или SD-карту. Откройте через «Device Sync» на боковой панели, выберите папку назначения, источники и нажмите «Передать на устройство».',
|
||||
q54: 'Что такое шаблон имени файла в Device Sync?',
|
||||
a54: 'Шаблон определяет, как треки будут называться и организованы. Используйте предустановки или создайте свой шаблон из токенов: {artist}, {album}, {title}, {track_number}, {disc_number}, {year} и / как разделитель папок.',
|
||||
q55: 'Работает ли Device Sync между разными платформами?',
|
||||
a55: 'Да. Манифест на устройстве содержит использованный шаблон. На другой ОС Psysonic корректно распознаёт уже существующие файлы.',
|
||||
s9: 'Device Sync',
|
||||
q56: 'Что такое интернет-радио?',
|
||||
a56: 'Страница интернет-радио позволяет воспроизводить прямые трансляции в Psysonic. Станции берутся с сервера Navidrome. Воспроизведение использует HTML5-аудио.',
|
||||
q57: 'Какие форматы потоков поддерживает интернет-радио?',
|
||||
a57: 'MP3, AAC, OGG Vorbis и большинство HLS-потоков. MP3 и AAC работают на всех платформах.',
|
||||
s10: 'Интернет-радио',
|
||||
q58: 'Откуда Psysonic берёт тексты песен?',
|
||||
a58: 'Три настраиваемых источника в Настройках → Тексты: сервер Navidrome, LRCLIB и NetEase Cloud Music. Каждый источник можно включить/отключить и приоритизировать перетаскиванием.',
|
||||
q59: 'Есть ли просмотр того, что слушают другие пользователи?',
|
||||
a59: 'Да. Нажмите на иконку трансляции в правом верхнем углу, чтобы увидеть, что слушают другие пользователи на вашем сервере Navidrome, с обновлением каждые 10 секунд.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь',
|
||||
@@ -845,6 +903,12 @@ export const ruTranslation = {
|
||||
trackPlural: 'треков',
|
||||
showRemaining: 'Осталось',
|
||||
showTotal: 'Всего',
|
||||
rgTrack: 'Т {{db}} дБ',
|
||||
rgAlbum: 'А {{db}} дБ',
|
||||
rgPeak: 'Пик {{pk}}',
|
||||
sourceOffline: 'Играет из офлайн-библиотеки',
|
||||
sourceHot: 'Играет из кэша',
|
||||
sourceStream: 'Играет из сетевого потока',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Статистика',
|
||||
@@ -1024,6 +1088,24 @@ export const ruTranslation = {
|
||||
addToPlaylistError: 'Ошибка при добавлении в плейлист',
|
||||
removeSuccess: 'Трек убран из плейлиста',
|
||||
removeError: 'Ошибка при удалении из плейлиста',
|
||||
importCSV: 'Импорт CSV',
|
||||
importCSVTooltip: 'Импорт из CSV Spotify',
|
||||
csvImportReport: 'Отчёт об импорте CSV',
|
||||
csvImportTotal: 'Всего',
|
||||
csvImportAdded: 'Добавлено',
|
||||
csvImportDuplicates: 'Дубликаты',
|
||||
csvImportNotFound: 'Не найдено',
|
||||
csvImportNetworkErrors: 'Ошибки сети',
|
||||
csvImportDuplicatesTitle: 'Дубликаты (уже в плейлисте):',
|
||||
csvImportNotFoundTitle: 'Не найденные треки:',
|
||||
csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):',
|
||||
csvImportDownloadReport: 'Скачать отчёт',
|
||||
csvImportClose: 'Закрыть',
|
||||
csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков',
|
||||
csvImportFailed: 'Не удалось импортировать CSV-файл',
|
||||
csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов',
|
||||
csvImportDownloadSuccess: 'Отчёт успешно скачан',
|
||||
csvImportDownloadError: 'Не удалось скачать отчёт',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Популярное',
|
||||
@@ -1077,4 +1159,77 @@ export const ruTranslation = {
|
||||
empty: 'Папка пуста',
|
||||
error: 'Ошибка загрузки',
|
||||
},
|
||||
deviceSync: {
|
||||
title: 'Синхронизация устройства',
|
||||
targetFolder: 'Папка назначения',
|
||||
noFolderChosen: 'Папка не выбрана',
|
||||
selectDrive: 'Выберите диск…',
|
||||
refreshDrives: 'Обновить диски',
|
||||
noDrivesDetected: 'Съёмные диски не обнаружены',
|
||||
browseManual: 'Обзор вручную…',
|
||||
free: 'свободно',
|
||||
notMountedVolume: 'Цель не находится на смонтированном томе. Диск мог быть отключён.',
|
||||
chooseFolder: 'Выбрать…',
|
||||
filenameTemplate: 'Шаблон имени файла',
|
||||
targetDevice: 'Целевое устройство',
|
||||
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Удалить файлы вне выборки',
|
||||
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
|
||||
confirmCleanup: 'Удалить {{count}} файл(ов) с устройства, не входящих в текущую выборку. Продолжить?',
|
||||
cleanupComplete: '{{count}} файл(ов) удалено с устройства.',
|
||||
selectedSources: 'Выбранные источники',
|
||||
noSourcesSelected: 'Источники не выбраны. Нажмите на элементы в браузере, чтобы добавить их.',
|
||||
clearAll: 'Очистить всё',
|
||||
tabPlaylists: 'Плейлисты',
|
||||
tabAlbums: 'Альбомы',
|
||||
tabArtists: 'Исполнители',
|
||||
searchPlaceholder: 'Поиск…',
|
||||
syncButton: 'Синхронизировать на устройство',
|
||||
actionTransfer: 'Перенести на устройство',
|
||||
actionDelete: 'Удалить с устройства',
|
||||
actionApplyAll: 'Применить все изменения',
|
||||
templatePreview: 'Предпросмотр',
|
||||
templatePresetStandard: 'Стандарт',
|
||||
templatePresetMultiDisc: 'Мульти-диск',
|
||||
templatePresetAltFolder: 'Альт. папка',
|
||||
tokenSlashHint: '/ = разделитель папок',
|
||||
cancel: 'Отмена',
|
||||
noTargetDir: 'Сначала выберите папку назначения.',
|
||||
noSources: 'Выберите хотя бы один источник.',
|
||||
noTracks: 'В выбранных источниках не найдено треков.',
|
||||
fetchError: 'Ошибка загрузки треков с сервера.',
|
||||
syncComplete: 'Синхронизация завершена.',
|
||||
dismiss: 'Закрыть',
|
||||
cancelSync: 'Отмена',
|
||||
syncCancelled: 'Синхронизация отменена ({{done}} / {{total}} перенесено).',
|
||||
colName: 'Название',
|
||||
colType: 'Тип',
|
||||
colStatus: 'Статус',
|
||||
onDevice: 'Управление устройством',
|
||||
addSources: 'Добавить…',
|
||||
syncResult: '{{done}} перенесено, {{skipped}} уже актуально ({{total}} всего)',
|
||||
deleteFromDevice: 'Пометить для удаления ({{count}})',
|
||||
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
|
||||
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
|
||||
manifestImported: 'Импортировано {{count}} источник(ов) из манифеста устройства.',
|
||||
statusSynced: 'Синхронизировано',
|
||||
statusPending: 'Ожидает',
|
||||
statusDeletion: 'Удаление',
|
||||
markForDeletion: 'Пометить для удаления',
|
||||
removeSource: 'Удалить',
|
||||
undoDeletion: 'Отменить удаление',
|
||||
syncInBackground: 'Синхронизация запущена в фоне — можно перейти в другой раздел.',
|
||||
syncInProgress: '{{done}} / {{total}} треков…',
|
||||
syncSummary: 'Сводка синхронизации',
|
||||
calculating: 'Вычисление необходимых данных…',
|
||||
filesToAdd: 'Файлы для добавления:',
|
||||
filesToDelete: 'Файлы для удаления:',
|
||||
netChange: 'Чистое изменение:',
|
||||
availableSpace: 'Доступное место на диске:',
|
||||
spaceWarning: 'Предупреждение: на целевом устройстве недостаточно сообщаемого места.',
|
||||
proceed: 'Продолжить синхронизацию',
|
||||
notEnoughSpace: 'Обнаружено недостаточно физического места на диске!',
|
||||
liveSearch: 'Live',
|
||||
randomAlbumsLabel: 'Случайные альбомы',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const zhTranslation = {
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
syncingTracks: '同步中 {{done}}/{{total}}…',
|
||||
cancelDownload: '取消下载',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
@@ -24,6 +25,7 @@ export const zhTranslation = {
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
folderBrowser: '文件夹浏览器',
|
||||
deviceSync: '设备同步',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
expandPlaylists: '展开播放列表',
|
||||
@@ -99,6 +101,7 @@ export const zhTranslation = {
|
||||
startRadio: '开始电台',
|
||||
instantMix: '即时混音',
|
||||
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||
cliMixNeedsTrack: '当前没有正在播放的内容 — 请先开始播放,然后再执行混音命令。',
|
||||
lfmLove: '在 Last.fm 上标记喜欢',
|
||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||
favorite: '收藏',
|
||||
@@ -150,6 +153,7 @@ export const zhTranslation = {
|
||||
trackDuration: '时长',
|
||||
trackTotal: '总计',
|
||||
columns: '列',
|
||||
resetColumns: '重置为默认',
|
||||
notFound: '未找到专辑。',
|
||||
bioModal: '艺术家简介',
|
||||
bioClose: '关闭',
|
||||
@@ -208,6 +212,11 @@ export const zhTranslation = {
|
||||
playAll: '全部播放',
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
|
||||
showingCount: '显示 {{filtered}} / {{total}}',
|
||||
clearArtistFilter: '清除艺术家筛选',
|
||||
noFilterResults: '所选筛选条件下无结果。',
|
||||
allArtists: '全部艺术家',
|
||||
},
|
||||
randomLanding: {
|
||||
title: '创建混音',
|
||||
@@ -343,6 +352,10 @@ export const zhTranslation = {
|
||||
offlineAlbumCount_plural: '{{n}} 张专辑',
|
||||
retry: '重试',
|
||||
serverSettings: '服务器设置',
|
||||
switchServerTitle: '切换服务器',
|
||||
switchServerHint: '点击选择其他已保存的服务器。',
|
||||
manageServers: '管理服务器…',
|
||||
switchFailed: '无法切换 — 无法连接服务器。',
|
||||
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
|
||||
lastfmSessionInvalid: '会话无效 — 点击重新连接',
|
||||
},
|
||||
@@ -370,6 +383,7 @@ export const zhTranslation = {
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
play: '播放',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
clearSelection: '清除选择',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
@@ -392,6 +406,10 @@ export const zhTranslation = {
|
||||
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
||||
durationMinutesOnly: '{{minutes}}分钟',
|
||||
updaterOpenGitHub: '在 GitHub 上打开',
|
||||
filters: '筛选器',
|
||||
more: '更多',
|
||||
yearRange: '年份范围',
|
||||
clearAll: '清除全部',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -514,6 +532,11 @@ export const zhTranslation = {
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
|
||||
discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。',
|
||||
discordTemplates: '自定义文本模板',
|
||||
discordTemplatesDesc: '自定义在 Discord 个人资料上显示的信息。变量:{title}, {artist}, {album}',
|
||||
discordTemplateDetails: '主行 (details)',
|
||||
discordTemplateState: '副行 (state)',
|
||||
discordTemplateLargeText: '专辑提示 (largeText)',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
@@ -560,6 +583,8 @@ export const zhTranslation = {
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
randomNavSplitTitle: '拆分混音导航',
|
||||
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
|
||||
tabInput: '输入',
|
||||
tabServer: '服务器',
|
||||
tabSystem: '系统',
|
||||
@@ -653,6 +678,13 @@ export const zhTranslation = {
|
||||
themeSchedulerNightTheme: '夜晚主题',
|
||||
themeSchedulerNightStart: '夜晚开始时间',
|
||||
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
|
||||
visualOptionsTitle: '视觉选项',
|
||||
coverArtBackground: '封面背景',
|
||||
coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景',
|
||||
playlistCoverPhoto: '播放列表封面照片',
|
||||
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
|
||||
showBitrate: '显示比特率',
|
||||
showBitrateSub: '在曲目列表中显示音频比特率',
|
||||
uiScaleTitle: '界面缩放',
|
||||
uiScaleLabel: '缩放',
|
||||
},
|
||||
@@ -759,6 +791,32 @@ export const zhTranslation = {
|
||||
a46: '热缓存(Alpha,设置 → 音乐库)将队列中后续曲目预加载到磁盘,实现即时播放,对于慢速或远程服务器尤为有用。Psysonic 保留当前曲目和后续 5 首,达到大小限制时淘汰旧条目。',
|
||||
q47: '可以将播放列表缓存到离线吗?',
|
||||
a47: '可以。在播放列表详情中点击下载图标,所有曲目将在后台下载。完全缓存后图标变为红色垃圾桶;再次点击即可从离线缓存中删除该播放列表。',
|
||||
q48: '什么是无限队列?',
|
||||
a48: '当队列播完且关闭了重复播放时,Psysonic会自动添加随机曲目,确保播放不会中断。自动添加的曲目显示在"— 自动添加 —"分隔符下方。',
|
||||
q49: '可以通过命令行控制Psysonic吗?',
|
||||
a49: '可以。示例:psysonic --player play / pause / next / prev,--player volume 75,--player seek 15,--player mute,--player shuffle,--player repeat off|all|one。还可以切换服务器、音频设备、筛选音乐库和搜索。完整帮助:psysonic --player --help。',
|
||||
q50: '如何管理播放列表?',
|
||||
a50: '通过侧边栏中的"播放列表"进入。点击"新建播放列表"创建。在详情页可拖拽排序、点击×删除曲目、通过搜索添加歌曲,还可使用"建议"获取智能推荐。',
|
||||
q51: '可以备份和恢复设置吗?',
|
||||
a51: '可以。设置→备份与恢复可将所有设置、服务器配置、主题和快捷键导出为JSON文件,在其他设备上导入即可一键恢复。',
|
||||
q52: '如何更改音频输出设备?',
|
||||
a52: '设置→音频→输出设备。Psysonic列出所有可用输出,选择后立即生效。若设备在应用启动后连接,可点击刷新按钮更新列表。',
|
||||
q53: '什么是设备同步?',
|
||||
a53: '设备同步可将专辑、播放列表或艺术家复制到USB或SD卡。通过侧边栏"Device Sync"打开,选择目标文件夹和来源,点击"传输到设备"。',
|
||||
q54: '设备同步中的文件名模板是什么?',
|
||||
a54: '模板控制曲目的命名和组织方式。使用预设或通过可点击的令牌自定义:{artist}、{album}、{title}、{track_number}、{disc_number}、{year},以及/作为文件夹分隔符。',
|
||||
q55: '设备同步支持跨平台使用吗?',
|
||||
a55: '支持。设备上的清单文件包含同步时使用的模板。在不同系统上,Psysonic能正确识别已存在的文件。',
|
||||
s9: 'Device Sync',
|
||||
q56: '什么是网络电台?',
|
||||
a56: '网络电台页面可在Psysonic中播放任何直播流。电台来自Navidrome服务器,可通过管理面板添加或导入.pls/.m3u文件。播放使用HTML5音频。',
|
||||
q57: '网络电台支持哪些流格式?',
|
||||
a57: 'MP3、AAC、OGG Vorbis及大多数HLS流。MP3和AAC在所有平台均可使用。',
|
||||
s10: '网络电台',
|
||||
q58: 'Psysonic从哪里获取歌词?',
|
||||
a58: '三个可配置来源(设置→歌词):Navidrome服务器、LRCLIB和网易云音乐。每个来源均可启用/禁用,并可拖拽调整优先级。',
|
||||
q59: '有没有查看其他用户正在听什么的功能?',
|
||||
a59: '有。点击右上角的广播图标,可查看Navidrome服务器上其他用户正在收听的内容,每10秒更新一次。',
|
||||
},
|
||||
queue: {
|
||||
title: '队列',
|
||||
@@ -790,6 +848,12 @@ export const zhTranslation = {
|
||||
trackPlural: '首曲目',
|
||||
showRemaining: '显示剩余时间',
|
||||
showTotal: '显示总时间',
|
||||
rgTrack: '曲目 {{db}} dB',
|
||||
rgAlbum: '专辑 {{db}} dB',
|
||||
rgPeak: '峰值 {{pk}}',
|
||||
sourceOffline: '正在从离线库播放',
|
||||
sourceHot: '正在从缓存播放',
|
||||
sourceStream: '正在从网络流播放',
|
||||
},
|
||||
statistics: {
|
||||
title: '统计',
|
||||
@@ -959,6 +1023,24 @@ export const zhTranslation = {
|
||||
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
|
||||
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
|
||||
addToPlaylistError: '添加到播放列表时出错',
|
||||
importCSV: '导入 CSV',
|
||||
importCSVTooltip: '从 Spotify CSV 导入',
|
||||
csvImportReport: 'CSV 导入报告',
|
||||
csvImportTotal: '总计',
|
||||
csvImportAdded: '已添加',
|
||||
csvImportDuplicates: '重复项',
|
||||
csvImportNotFound: '未找到',
|
||||
csvImportNetworkErrors: '网络错误',
|
||||
csvImportDuplicatesTitle: '重复曲目(已在播放列表中):',
|
||||
csvImportNotFoundTitle: '未找到的曲目:',
|
||||
csvImportNetworkErrorsTitle: '网络错误(可重试导入):',
|
||||
csvImportDownloadReport: '下载报告',
|
||||
csvImportClose: '关闭',
|
||||
csvImportNoValidTracks: 'CSV 文件中未找到有效曲目',
|
||||
csvImportFailed: 'CSV 文件导入失败',
|
||||
csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项',
|
||||
csvImportDownloadSuccess: '报告下载成功',
|
||||
csvImportDownloadError: '报告下载失败',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: '最常播放',
|
||||
@@ -1012,4 +1094,77 @@ export const zhTranslation = {
|
||||
empty: '空文件夹',
|
||||
error: '加载失败',
|
||||
},
|
||||
deviceSync: {
|
||||
title: '设备同步',
|
||||
targetFolder: '目标文件夹',
|
||||
noFolderChosen: '未选择文件夹',
|
||||
selectDrive: '选择驱动器…',
|
||||
refreshDrives: '刷新驱动器',
|
||||
noDrivesDetected: '未检测到可移动驱动器',
|
||||
browseManual: '手动浏览…',
|
||||
free: '可用',
|
||||
notMountedVolume: '目标不在已挂载的卷上。驱动器可能已断开。',
|
||||
chooseFolder: '选择…',
|
||||
filenameTemplate: '文件名模板',
|
||||
targetDevice: '目标设备',
|
||||
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
|
||||
cleanupButton: '删除不在选择范围内的文件',
|
||||
cleanupNothingToDelete: '无需删除 — 设备已同步。',
|
||||
confirmCleanup: '将从设备中删除 {{count}} 个不在当前选择范围内的文件。继续?',
|
||||
cleanupComplete: '已从设备中删除 {{count}} 个文件。',
|
||||
selectedSources: '已选来源',
|
||||
noSourcesSelected: '未选择来源。点击浏览器中的项目以添加。',
|
||||
clearAll: '全部清除',
|
||||
tabPlaylists: '播放列表',
|
||||
tabAlbums: '专辑',
|
||||
tabArtists: '艺术家',
|
||||
searchPlaceholder: '搜索…',
|
||||
syncButton: '同步到设备',
|
||||
actionTransfer: '传输到设备',
|
||||
actionDelete: '从设备删除',
|
||||
actionApplyAll: '应用所有更改',
|
||||
templatePreview: '预览',
|
||||
templatePresetStandard: '标准',
|
||||
templatePresetMultiDisc: '多碟',
|
||||
templatePresetAltFolder: '备用文件夹',
|
||||
tokenSlashHint: '/ = 文件夹分隔符',
|
||||
cancel: '取消',
|
||||
noTargetDir: '请先选择目标文件夹。',
|
||||
noSources: '请至少选择一个来源。',
|
||||
noTracks: '所选来源中未找到曲目。',
|
||||
fetchError: '从服务器加载曲目失败。',
|
||||
syncComplete: '同步完成。',
|
||||
dismiss: '关闭',
|
||||
cancelSync: '取消',
|
||||
syncCancelled: '同步已取消(已传输 {{done}} / {{total}})。',
|
||||
colName: '名称',
|
||||
colType: '类型',
|
||||
colStatus: '状态',
|
||||
onDevice: '设备管理器',
|
||||
addSources: '添加…',
|
||||
syncResult: '已传输 {{done}},{{skipped}} 已是最新(共 {{total}})',
|
||||
deleteFromDevice: '标记为删除({{count}})',
|
||||
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}?',
|
||||
deleteComplete: '已从设备删除 {{count}} 个项目。',
|
||||
manifestImported: '已从设备清单导入 {{count}} 个来源。',
|
||||
statusSynced: '已同步',
|
||||
statusPending: '待处理',
|
||||
statusDeletion: '删除中',
|
||||
markForDeletion: '标记为删除',
|
||||
removeSource: '移除',
|
||||
undoDeletion: '撤销删除',
|
||||
syncInBackground: '同步已在后台启动 — 您可以离开此页面。',
|
||||
syncInProgress: '{{done}} / {{total}} 首曲目…',
|
||||
syncSummary: '同步摘要',
|
||||
calculating: '正在计算所需数据…',
|
||||
filesToAdd: '要添加的文件:',
|
||||
filesToDelete: '要删除的文件:',
|
||||
netChange: '净变化:',
|
||||
availableSpace: '可用磁盘空间:',
|
||||
spaceWarning: '警告:目标设备的可用空间不足。',
|
||||
proceed: '继续同步',
|
||||
notEnoughSpace: '检测到物理磁盘空间不足!',
|
||||
liveSearch: '实时',
|
||||
randomAlbumsLabel: '随机专辑',
|
||||
},
|
||||
};
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
@@ -7,7 +7,7 @@ import './styles/layout.css';
|
||||
import './styles/components.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import ArtistRow from '../components/ArtistRow';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||
|
||||
@@ -35,8 +36,20 @@ export default function AdvancedSearch() {
|
||||
const qFromUrl = params.get('q') ?? '';
|
||||
const navigate = useNavigate();
|
||||
const psyDrag = useDragDrop();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
|
||||
const { playTrack, openContextMenu } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
openContextMenu: s.openContextMenu,
|
||||
}))
|
||||
);
|
||||
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
const [query, setQuery] = useState(params.get('q') ?? '');
|
||||
const [genre, setGenre] = useState('');
|
||||
@@ -45,6 +58,9 @@ export default function AdvancedSearch() {
|
||||
const [resultType, setResultType] = useState<ResultType>('all');
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [results, setResults] = useState<Results | null>(null);
|
||||
const total = results
|
||||
? results.artists.length + results.albums.length + results.songs.length
|
||||
: 0;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [genreNote, setGenreNote] = useState(false);
|
||||
@@ -120,10 +136,6 @@ export default function AdvancedSearch() {
|
||||
runSearch({ query, genre, yearFrom, yearTo, resultType });
|
||||
};
|
||||
|
||||
const total = results
|
||||
? results.artists.length + results.albums.length + results.songs.length
|
||||
: 0;
|
||||
|
||||
const typeOptions: { id: ResultType; label: string }[] = [
|
||||
{ id: 'all', label: t('search.advancedAll') },
|
||||
{ id: 'artists', label: t('search.artists') },
|
||||
@@ -288,11 +300,15 @@ export default function AdvancedSearch() {
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}
|
||||
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -136,6 +137,22 @@ const handleEnqueueAll = () => {
|
||||
enqueue(tracks);
|
||||
};
|
||||
|
||||
const handleShuffleAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
const shuffled = [...tracks];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
if (shuffled[0]) playTrack(shuffled[0], shuffled);
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
@@ -352,6 +369,7 @@ const handleEnqueueAll = () => {
|
||||
onDownload={handleDownload}
|
||||
onPlayAll={handlePlayAll}
|
||||
onEnqueueAll={handleEnqueueAll}
|
||||
onShuffleAll={handleShuffleAll}
|
||||
onBio={handleBio}
|
||||
onCloseBio={() => setBioOpen(false)}
|
||||
offlineStatus={resolvedOfflineStatus}
|
||||
@@ -378,8 +396,9 @@ const handleEnqueueAll = () => {
|
||||
{songs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
className="input"
|
||||
className="input-search"
|
||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||
placeholder={t('albumDetail.filterSongs')}
|
||||
value={filterText}
|
||||
@@ -387,9 +406,12 @@ const handleEnqueueAll = () => {
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setFilterText('')}
|
||||
>×</button>
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function Artists() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
|
||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+334
-51
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
@@ -18,16 +18,25 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
|
||||
const FAV_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const MIN_YEAR = 1950;
|
||||
|
||||
// Columns that support 3-state sorting (asc → desc → reset)
|
||||
const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration']);
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -36,10 +45,25 @@ export default function Favorites() {
|
||||
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── Sorting (3-state: asc → desc → reset) ────────────────────────────────
|
||||
const [sortKey, setSortKey] = useState<string>('natural');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [sortClickCount, setSortClickCount] = useState(0);
|
||||
|
||||
// ── Artist filtering ─────────────────────────────────────────────────────
|
||||
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
|
||||
|
||||
// ── Genre filtering ──────────────────────────────────────────────────────
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
|
||||
// ── Year range filtering ─────────────────────────────────────────────────
|
||||
const [yearRange, setYearRange] = useState<[number, number]>([MIN_YEAR, CURRENT_YEAR]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// ── Column resize/visibility (must be before early return) ───────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
startResize, toggleColumn, resetColumns,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
@@ -75,6 +99,36 @@ export default function Favorites() {
|
||||
setSongs(prev => prev.filter(s => s.id !== id));
|
||||
}
|
||||
|
||||
// ── Sorting logic ─────────────────────────────────────────────────────────
|
||||
const handleSortClick = (key: string) => {
|
||||
if (!SORTABLE_COLUMNS.has(key)) return;
|
||||
|
||||
if (sortKey === key) {
|
||||
const nextCount = sortClickCount + 1;
|
||||
if (nextCount >= 3) {
|
||||
// Reset to natural order (favorite addition order)
|
||||
setSortKey('natural');
|
||||
setSortDir('asc');
|
||||
setSortClickCount(0);
|
||||
} else {
|
||||
// Toggle direction
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
setSortClickCount(nextCount);
|
||||
}
|
||||
} else {
|
||||
// Start new sort on this column
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
setSortClickCount(1);
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIndicator = (key: string) => {
|
||||
if (sortKey !== key) return null;
|
||||
if (sortClickCount === 0) return null;
|
||||
return sortDir === 'asc' ? <ArrowUp size={12} style={{ marginLeft: 4, opacity: 0.7 }} /> : <ArrowDown size={12} style={{ marginLeft: 4, opacity: 0.7 }} />;
|
||||
};
|
||||
|
||||
function unfavoriteStation(id: string) {
|
||||
setRadioStations(prev => prev.filter(s => s.id !== id));
|
||||
try {
|
||||
@@ -151,6 +205,68 @@ export default function Favorites() {
|
||||
loadAll();
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// ── Filter & sort logic ──────────────────────────────────────────────────
|
||||
const filteredSongs = useMemo(() => {
|
||||
return songs.filter(s => {
|
||||
// Remove unfavorited
|
||||
if (starredOverrides[s.id] === false) return false;
|
||||
|
||||
// Artist filter
|
||||
if (selectedArtist) {
|
||||
const artistMatch = s.artistId === selectedArtist ||
|
||||
s.artist === selectedArtist ||
|
||||
s.albumArtist === selectedArtist;
|
||||
if (!artistMatch) return false;
|
||||
}
|
||||
|
||||
// Genre filter
|
||||
if (selectedGenres.length > 0) {
|
||||
const songGenre = s.genre || '';
|
||||
const hasMatchingGenre = selectedGenres.some(g =>
|
||||
songGenre.toLowerCase().includes(g.toLowerCase())
|
||||
);
|
||||
if (!hasMatchingGenre) return false;
|
||||
}
|
||||
|
||||
// Year range filter — only applied when range is non-default; songs without year are excluded
|
||||
if (yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) {
|
||||
if (s.year === undefined || s.year < yearRange[0] || s.year > yearRange[1]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [songs, starredOverrides, selectedArtist, selectedGenres, yearRange]);
|
||||
|
||||
// ── Sort logic ───────────────────────────────────────────────────────────
|
||||
const visibleSongs = useMemo(() => {
|
||||
if (sortKey === 'natural' || sortClickCount === 0) {
|
||||
return filteredSongs;
|
||||
}
|
||||
|
||||
const sorted = [...filteredSongs];
|
||||
const multiplier = sortDir === 'asc' ? 1 : -1;
|
||||
|
||||
return sorted.sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'title':
|
||||
return multiplier * (a.title || '').localeCompare(b.title || '');
|
||||
case 'artist':
|
||||
return multiplier * ((a.artist || '').localeCompare(b.artist || ''));
|
||||
case 'album':
|
||||
return multiplier * ((a.album || '').localeCompare(b.album || ''));
|
||||
case 'rating':
|
||||
const ratingA = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
|
||||
const ratingB = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
||||
return multiplier * (ratingA - ratingB);
|
||||
case 'duration':
|
||||
return multiplier * ((a.duration || 0) - (b.duration || 0));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [filteredSongs, sortKey, sortDir, sortClickCount, ratings, userRatingOverrides]);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -158,9 +274,8 @@ export default function Favorites() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0;
|
||||
// Check if user has any favorites (using original unfiltered lists)
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0 || radioStations.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
@@ -194,30 +309,132 @@ export default function Favorites() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleSongs.length > 0 && (
|
||||
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||
<section className="album-row-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}
|
||||
>
|
||||
<Play size={15} />
|
||||
{t('favorites.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
<ListPlus size={15} />
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
{/* ── Section Header with Stats & Filters ───────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
{/* Title Row with showing X of Y indicator */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
{selectedArtist
|
||||
? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length, artist: selectedArtist })
|
||||
: t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={visibleSongs.length === 0}
|
||||
onClick={() => {
|
||||
if (visibleSongs.length === 0) return;
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}
|
||||
>
|
||||
<Play size={15} />
|
||||
{t('favorites.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={visibleSongs.length === 0}
|
||||
onClick={() => {
|
||||
if (visibleSongs.length === 0) return;
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
<ListPlus size={15} />
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
|
||||
{/* Filter Toggle Button */}
|
||||
<button
|
||||
className={`btn ${showFilters || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => setShowFilters(v => !v)}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
{t('common.filters')}
|
||||
</button>
|
||||
|
||||
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setSelectedArtist(null);
|
||||
setSelectedGenres([]);
|
||||
setYearRange([MIN_YEAR, CURRENT_YEAR]);
|
||||
setSortKey('natural');
|
||||
setSortClickCount(0);
|
||||
}}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
{showFilters && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '0.75rem', background: 'var(--surface)', borderRadius: '8px', marginTop: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</div>
|
||||
|
||||
{/* Year Range Filter */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--muted)' }}>
|
||||
<span>{t('common.yearRange')}:</span>
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 500 }}>{yearRange[0]} - {yearRange[1]}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_YEAR}
|
||||
max={CURRENT_YEAR}
|
||||
value={yearRange[0]}
|
||||
onChange={e => {
|
||||
const val = parseInt(e.target.value);
|
||||
setYearRange(prev => [Math.min(val, prev[1] - 1), prev[1]]);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_YEAR}
|
||||
max={CURRENT_YEAR}
|
||||
value={yearRange[1]}
|
||||
onChange={e => {
|
||||
const val = parseInt(e.target.value);
|
||||
setYearRange(prev => [prev[0], Math.max(val, prev[0] + 1)]);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedArtist && (
|
||||
<button
|
||||
onClick={() => setSelectedArtist(null)}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
fontSize: '0.75rem',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
{t('favorites.clearArtistFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
|
||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||
@@ -255,6 +472,35 @@ export default function Favorites() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column visibility picker */}
|
||||
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
|
||||
<div className="tracklist-col-picker">
|
||||
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{FAV_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
|
||||
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="tracklist-col-picker-divider" />
|
||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
@@ -283,17 +529,34 @@ export default function Favorites() {
|
||||
}
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
const canSort = SORTABLE_COLUMNS.has('title');
|
||||
return (
|
||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
paddingLeft: 12,
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => handleSortClick('title')}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
{canSort && getSortIndicator('title')}
|
||||
</div>
|
||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (key === 'remove') return <div key="remove" />;
|
||||
|
||||
const isCentered = key === 'duration' || key === 'rating';
|
||||
const canSort = SORTABLE_COLUMNS.has(key);
|
||||
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div
|
||||
@@ -304,35 +567,19 @@ export default function Favorites() {
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => canSort && handleSortClick(key)}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
{canSort && getSortIndicator(key)}
|
||||
</div>
|
||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{FAV_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
|
||||
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
@@ -352,7 +599,7 @@ export default function Favorites() {
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -392,6 +639,34 @@ export default function Favorites() {
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'album': return (
|
||||
<div key="album" className="track-artist-cell">
|
||||
{song.albumId ? (
|
||||
<span
|
||||
className="track-artist track-artist-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/album/${song.albumId}`);
|
||||
}}
|
||||
>
|
||||
{song.album}
|
||||
</span>
|
||||
) : (
|
||||
<span className="track-artist">{song.album}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">
|
||||
{song.suffix?.toUpperCase()}
|
||||
{song.suffix && song.bitRate && ' · '}
|
||||
{song.bitRate && `${song.bitRate} kbps`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'rating': return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
@@ -417,7 +692,15 @@ export default function Favorites() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state when filters return no results */}
|
||||
{visibleSongs.length === 0 && (selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--muted)' }}>
|
||||
{t('favorites.noFilterResults')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
||||
+31
-7
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle, WifiOff } from 'lucide-react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle, WifiOff, HardDriveUpload, Mic2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FaqItem { q: string; a: string; }
|
||||
@@ -37,15 +37,19 @@ export default function Help() {
|
||||
icon: <Play size={18} />,
|
||||
title: t('help.s2'),
|
||||
items: [
|
||||
{ q: t('help.q4'), a: t('help.a4') },
|
||||
{ q: t('help.q5'), a: t('help.a5') },
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q4'), a: t('help.a4') },
|
||||
{ q: t('help.q5'), a: t('help.a5') },
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
{ q: t('help.q29'), a: t('help.a29') },
|
||||
{ q: t('help.q48'), a: t('help.a48') },
|
||||
{ q: t('help.q30'), a: t('help.a30') },
|
||||
{ q: t('help.q58'), a: t('help.a58') },
|
||||
{ q: t('help.q49'), a: t('help.a49') },
|
||||
{ q: t('help.q59'), a: t('help.a59') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -58,6 +62,7 @@ export default function Help() {
|
||||
{ q: t('help.q38'), a: t('help.a38') },
|
||||
{ q: t('help.q39'), a: t('help.a39') },
|
||||
{ q: t('help.q40'), a: t('help.a40') },
|
||||
{ q: t('help.q50'), a: t('help.a50') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
{ q: t('help.q25'), a: t('help.a25') },
|
||||
],
|
||||
@@ -71,11 +76,13 @@ export default function Help() {
|
||||
{ q: t('help.q42'), a: t('help.a42') },
|
||||
{ q: t('help.q43'), a: t('help.a43') },
|
||||
{ q: t('help.q13'), a: t('help.a13') },
|
||||
{ q: t('help.q32'), a: t('help.a32') },
|
||||
{ q: t('help.q15'), a: t('help.a15') },
|
||||
{ q: t('help.q31'), a: t('help.a31') },
|
||||
{ q: t('help.q32'), a: t('help.a32') },
|
||||
{ q: t('help.q51'), a: t('help.a51') },
|
||||
{ q: t('help.q44'), a: t('help.a44') },
|
||||
{ q: t('help.q45'), a: t('help.a45') },
|
||||
{ q: t('help.q52'), a: t('help.a52') },
|
||||
{ q: t('help.q46'), a: t('help.a46') },
|
||||
],
|
||||
},
|
||||
@@ -87,6 +94,14 @@ export default function Help() {
|
||||
{ q: t('help.q17'), a: t('help.a17') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Mic2 size={18} />,
|
||||
title: t('help.s10'),
|
||||
items: [
|
||||
{ q: t('help.q56'), a: t('help.a56') },
|
||||
{ q: t('help.q57'), a: t('help.a57') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Shuffle size={18} />,
|
||||
title: t('help.s7'),
|
||||
@@ -96,6 +111,15 @@ export default function Help() {
|
||||
{ q: t('help.q28'), a: t('help.a28') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <HardDriveUpload size={18} />,
|
||||
title: t('help.s9'),
|
||||
items: [
|
||||
{ q: t('help.q53'), a: t('help.a53') },
|
||||
{ q: t('help.q54'), a: t('help.a54') },
|
||||
{ q: t('help.q55'), a: t('help.a55') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <WifiOff size={18} />,
|
||||
title: t('help.s8'),
|
||||
|
||||
@@ -44,7 +44,7 @@ function renderStars(rating?: number) {
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Star key={i} size={13}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'rgba(255,255,255,0.4)'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--ctp-overlay1)'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -175,7 +175,7 @@ const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, alb
|
||||
return (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{album}</em></h3>
|
||||
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'var(--text-muted)' }}>{album}</em></h3>
|
||||
{albumId && (
|
||||
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
|
||||
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
|
||||
@@ -429,13 +429,13 @@ export default function NowPlaying() {
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
<Heart size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'currentColor'} />
|
||||
</button>
|
||||
<button
|
||||
className="np-star-btn"
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
style={{ color: activeTab === 'lyrics' && isQueueVisible ? 'var(--accent)' : 'white' }}
|
||||
style={{ color: activeTab === 'lyrics' && isQueueVisible ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
<MicVocal size={17} />
|
||||
</button>
|
||||
|
||||
+914
-118
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import ArtistRow from '../components/ArtistRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
function formatDuration(s: number) {
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
|
||||
@@ -19,10 +21,25 @@ export default function SearchResults() {
|
||||
const query = params.get('q') ?? '';
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const psyDrag = useDragDrop();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
enqueue: s.enqueue,
|
||||
openContextMenu: s.openContextMenu,
|
||||
currentTrack: s.currentTrack,
|
||||
}))
|
||||
);
|
||||
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setResults(null); return; }
|
||||
@@ -82,9 +99,14 @@ export default function SearchResults() {
|
||||
{results.songs.map(song => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}
|
||||
onDoubleClick={() => playSong(song, results.songs)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -116,7 +138,7 @@ export default function SearchResults() {
|
||||
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
|
||||
<span className="track-codec" style={{ alignSelf: 'center' }}>
|
||||
{[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
{[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
|
||||
+193
-48
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock
|
||||
} from 'lucide-react';
|
||||
import i18n from '../i18n';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
@@ -31,8 +31,9 @@ import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGloba
|
||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import Equalizer from '../components/Equalizer';
|
||||
@@ -114,6 +115,11 @@ const CONTRIBUTORS = [
|
||||
'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)',
|
||||
'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)',
|
||||
'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)',
|
||||
'Opus audio playback via symphonia-adapter-libopus with bundled libopus (PR #183)',
|
||||
'CLI player controls with D-Bus forwarding, shell completions for bash/zsh, library/audio-device/instant-mix CLI commands, active server switcher in header (PR #187)',
|
||||
'Streaming playback stability: stream-first start, seek recovery, crossfade/gapless backup preload, hot-cache promotion (PR #200)',
|
||||
'ReplayGain values in Queue tech strip (PR #196)',
|
||||
'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -138,6 +144,16 @@ const CONTRIBUTORS = [
|
||||
'Spanish (es) translation — 964 strings (PR #159)',
|
||||
'Column-header sorting for albums & playlists (PR #160)',
|
||||
'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)',
|
||||
'3 visual toggles: cover art background, playlist cover photo, show bitrate badge (PR #181)',
|
||||
'8 community themes (AMOLED Black, Monochrome Dark, Amber Night, Phosphor Green, Midnight Blue, Rose Dark, Sepia Dark, Ice Blue) + waveform live theme update (PR #182)',
|
||||
'Favorites redesign: sortable columns, genre filter, age range filter, new columns (PR #184)',
|
||||
'Albums and playlist headers redesign with improved layout and theme integration (PR #186)',
|
||||
'Tracklist column picker overflow fix in AlbumTrackList (PR #188)',
|
||||
'Spotify CSV playlist import (PR #190)',
|
||||
'Context menu for songs in AdvancedSearch and SearchResults (PR #191)',
|
||||
'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)',
|
||||
'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)',
|
||||
'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -395,6 +411,7 @@ export default function Settings() {
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [contributorsOpen, setContributorsOpen] = useState(false);
|
||||
const [fontPickerOpen, setFontPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
||||
@@ -569,23 +586,11 @@ export default function Settings() {
|
||||
|
||||
const switchToServer = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
try {
|
||||
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('/');
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
} catch {
|
||||
const ok = await switchActiveServer(server);
|
||||
if (ok) {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'ok' }));
|
||||
navigate('/');
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
};
|
||||
@@ -1155,6 +1160,41 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div style={{ paddingLeft: 16, paddingTop: 8, paddingBottom: 8 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 8 }}>{t('settings.discordTemplates')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>{t('settings.discordTemplatesDesc')}</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateDetails')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateDetails}
|
||||
onChange={e => auth.setDiscordTemplateDetails(e.target.value)}
|
||||
placeholder="{artist} - {title}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateState')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateState}
|
||||
onChange={e => auth.setDiscordTemplateState(e.target.value)}
|
||||
placeholder="{album}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateLargeText')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateLargeText}
|
||||
onChange={e => auth.setDiscordTemplateLargeText(e.target.value)}
|
||||
placeholder="{album}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="settings-section-divider" />
|
||||
@@ -1612,6 +1652,47 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.visualOptionsTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<ZoomIn size={18} />
|
||||
@@ -1672,31 +1753,58 @@ export default function Settings() {
|
||||
<h2>{t('settings.font')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{(
|
||||
[
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ justifyContent: 'space-between', width: '100%', fontFamily: 'var(--font-sans)' }}
|
||||
onClick={() => setFontPickerOpen(o => !o)}
|
||||
>
|
||||
<span>{
|
||||
([
|
||||
{ id: 'inter', label: 'Inter' },
|
||||
{ id: 'outfit', label: 'Outfit' },
|
||||
{ id: 'dm-sans', label: 'DM Sans' },
|
||||
{ id: 'nunito', label: 'Nunito' },
|
||||
{ id: 'rubik', label: 'Rubik' },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk' },
|
||||
{ id: 'figtree', label: 'Figtree' },
|
||||
{ id: 'manrope', label: 'Manrope' },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans' },
|
||||
{ id: 'lexend', label: 'Lexend' },
|
||||
{ id: 'geist', label: 'Geist' },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||
] as { id: FontId; label: string }[]).find(f => f.id === fontStore.font)?.label ?? fontStore.font
|
||||
}</span>
|
||||
<ChevronDown size={14} style={{ color: 'var(--text-muted)', transform: fontPickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
|
||||
</button>
|
||||
{fontPickerOpen && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||
{(
|
||||
[
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
|
||||
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
|
||||
] as { id: FontId; label: string; stack: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
||||
onClick={() => { fontStore.setFont(f.id); setFontPickerOpen(false); }}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1982,7 +2090,18 @@ export default function Settings() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
{srv.url.startsWith('https://') && (
|
||||
<Lock size={11} style={{ color: 'var(--positive)', flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{srv.url.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||
<User size={11} />
|
||||
{srv.username}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
@@ -2526,8 +2645,15 @@ function SidebarCustomizer() {
|
||||
const dropTargetRef = useRef<DropTarget>(null);
|
||||
const itemsRef = useRef(items);
|
||||
itemsRef.current = items;
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
|
||||
|
||||
const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library');
|
||||
const libraryItems = items.filter(cfg => {
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
});
|
||||
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2557,10 +2683,13 @@ function SidebarCustomizer() {
|
||||
const [moved] = sectionItems.splice(fromIdx, 1);
|
||||
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||
|
||||
// Merge reordered section back into flat items array
|
||||
// Merge reordered section back into flat items array.
|
||||
// Only update positions of the *visible* items (same filter as libraryItems/systemItems)
|
||||
// so hidden entries like randomMix/randomAlbums are never overwritten with undefined.
|
||||
const all = [...itemsRef.current];
|
||||
const visibleIds = new Set(sectionItems.map(c => c.id));
|
||||
const positions = all.map((cfg, i) => ({ cfg, i }))
|
||||
.filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection)
|
||||
.filter(({ cfg }) => visibleIds.has(cfg.id))
|
||||
.map(({ i }) => i);
|
||||
positions.forEach((pos, i) => { all[pos] = sectionItems[i]; });
|
||||
setItems(all);
|
||||
@@ -2626,6 +2755,22 @@ function SidebarCustomizer() {
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.randomNavSplitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.randomNavSplitTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={randomNavMode === 'separate'}
|
||||
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Library block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
|
||||
+21
-1
@@ -61,6 +61,9 @@ interface AuthState {
|
||||
minimizeToTray: boolean;
|
||||
discordRichPresence: boolean;
|
||||
enableAppleMusicCoversDiscord: boolean;
|
||||
discordTemplateDetails: string;
|
||||
discordTemplateState: string;
|
||||
discordTemplateLargeText: string;
|
||||
useCustomTitlebar: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
lyricsServerFirst: boolean;
|
||||
@@ -187,6 +190,9 @@ interface AuthState {
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setDiscordRichPresence: (v: boolean) => void;
|
||||
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
|
||||
setDiscordTemplateDetails: (v: string) => void;
|
||||
setDiscordTemplateState: (v: string) => void;
|
||||
setDiscordTemplateLargeText: (v: string) => void;
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setLyricsServerFirst: (v: boolean) => void;
|
||||
@@ -212,6 +218,11 @@ interface AuthState {
|
||||
setMixMinRatingArtist: (v: number) => void;
|
||||
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
|
||||
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
||||
|
||||
/** Navigation style for Mix pages: single hub ('hub') or separate sidebar entries ('separate'). */
|
||||
randomNavMode: 'hub' | 'separate';
|
||||
setRandomNavMode: (v: 'hub' | 'separate') => void;
|
||||
|
||||
logout: () => void;
|
||||
|
||||
// Derived
|
||||
@@ -280,6 +291,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
enableAppleMusicCoversDiscord: false,
|
||||
discordTemplateDetails: '{artist} - {title}',
|
||||
discordTemplateState: '{album}',
|
||||
discordTemplateLargeText: '{album}',
|
||||
useCustomTitlebar: false,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
@@ -304,6 +318,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
mixMinRatingSong: 0,
|
||||
mixMinRatingAlbum: 0,
|
||||
mixMinRatingArtist: 0,
|
||||
randomNavMode: 'hub',
|
||||
musicFolders: [],
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 0,
|
||||
@@ -401,6 +416,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
|
||||
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
|
||||
setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }),
|
||||
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
|
||||
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||
@@ -457,6 +475,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
|
||||
setRandomNavMode: (v) => set({ randomNavMode: v }),
|
||||
|
||||
setMusicFolders: (folders) => {
|
||||
const sid = get().activeServerId;
|
||||
@@ -546,7 +565,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
const s = get();
|
||||
const server = s.servers.find(srv => srv.id === s.activeServerId);
|
||||
if (!server?.url) return '';
|
||||
return server.url.startsWith('http') ? server.url : `http://${server.url}`;
|
||||
const base = server.url.startsWith('http') ? server.url : `http://${server.url}`;
|
||||
return base.replace(/\/$/, '');
|
||||
},
|
||||
|
||||
getActiveServer: () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface DeviceSyncJobState {
|
||||
jobId: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'running' | 'done' | 'cancelled';
|
||||
|
||||
startSync: (jobId: string, total: number) => void;
|
||||
updateProgress: (done: number, skipped: number, failed: number) => void;
|
||||
complete: (done: number, skipped: number, failed: number) => void;
|
||||
cancel: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
|
||||
jobId: null,
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
status: 'idle',
|
||||
|
||||
startSync: (jobId, total) =>
|
||||
set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }),
|
||||
|
||||
updateProgress: (done, skipped, failed) =>
|
||||
set({ done, skipped, failed }),
|
||||
|
||||
complete: (done, skipped, failed) =>
|
||||
set({ done, skipped, failed, status: 'done' }),
|
||||
|
||||
cancel: () =>
|
||||
set({ status: 'cancelled' }),
|
||||
|
||||
reset: () =>
|
||||
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
|
||||
}));
|
||||
@@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface DeviceSyncSource {
|
||||
type: 'album' | 'playlist' | 'artist';
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DeviceSyncState {
|
||||
targetDir: string | null;
|
||||
filenameTemplate: string;
|
||||
sources: DeviceSyncSource[]; // persistent device content list
|
||||
checkedIds: string[]; // currently checked for bulk actions (not persisted)
|
||||
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
|
||||
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
|
||||
scanning: boolean; // true while scanning the device
|
||||
|
||||
setTargetDir: (dir: string | null) => void;
|
||||
setFilenameTemplate: (t: string) => void;
|
||||
addSource: (source: DeviceSyncSource) => void;
|
||||
removeSource: (id: string) => void;
|
||||
clearSources: () => void;
|
||||
toggleChecked: (id: string) => void;
|
||||
setCheckedIds: (ids: string[]) => void;
|
||||
markForDeletion: (ids: string[]) => void;
|
||||
unmarkDeletion: (id: string) => void;
|
||||
clearPendingDeletion: () => void;
|
||||
removeSources: (ids: string[]) => void;
|
||||
setDeviceFilePaths: (paths: string[]) => void;
|
||||
setScanning: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
targetDir: null,
|
||||
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
|
||||
sources: [],
|
||||
checkedIds: [],
|
||||
pendingDeletion: [],
|
||||
deviceFilePaths: [],
|
||||
scanning: false,
|
||||
|
||||
setTargetDir: (dir) => set({ targetDir: dir }),
|
||||
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
|
||||
|
||||
addSource: (source) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.some((x) => x.id === source.id)
|
||||
? s.sources
|
||||
: [...s.sources, source],
|
||||
})),
|
||||
|
||||
removeSource: (id) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => x.id !== id),
|
||||
checkedIds: s.checkedIds.filter((x) => x !== id),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
|
||||
|
||||
toggleChecked: (id) =>
|
||||
set((s) => ({
|
||||
checkedIds: s.checkedIds.includes(id)
|
||||
? s.checkedIds.filter((x) => x !== id)
|
||||
: [...s.checkedIds, id],
|
||||
})),
|
||||
|
||||
setCheckedIds: (ids) => set({ checkedIds: ids }),
|
||||
|
||||
markForDeletion: (ids) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])],
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
unmarkDeletion: (id) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearPendingDeletion: () => set({ pendingDeletion: [] }),
|
||||
|
||||
removeSources: (ids) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => !ids.includes(x.id)),
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }),
|
||||
setScanning: (v) => set({ scanning: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_device_sync',
|
||||
partialize: (s) => ({
|
||||
targetDir: s.targetDir,
|
||||
filenameTemplate: s.filenameTemplate,
|
||||
sources: s.sources,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend';
|
||||
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono';
|
||||
|
||||
interface FontState {
|
||||
font: FontId;
|
||||
|
||||
+273
-29
@@ -3,8 +3,8 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
@@ -64,7 +64,7 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
};
|
||||
}
|
||||
|
||||
function shuffleArray<T>(items: T[]): T[] {
|
||||
export function shuffleArray<T>(items: T[]): T[] {
|
||||
const arr = [...items];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
@@ -113,6 +113,13 @@ async function buildInfiniteQueueCandidates(
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
currentRadio: InternetRadioStation | null;
|
||||
/** Latches the source used to start the currently playing track. */
|
||||
currentPlaybackSource: PlaybackSourceKind | null;
|
||||
/**
|
||||
* Subsonic track id for which `audio_preload` finished into the engine RAM slot (see `audio:preload-ready`).
|
||||
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
|
||||
*/
|
||||
enginePreloadedTrackId: string | null;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
@@ -131,6 +138,8 @@ interface PlayerState {
|
||||
|
||||
playRadio: (station: InternetRadioStation) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
/** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */
|
||||
reseedQueueForInstantMix: (track: Track) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
@@ -174,12 +183,12 @@ interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
|
||||
type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
|
||||
queueIndex?: number;
|
||||
playlistId?: string;
|
||||
playlistSongIndex?: number;
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void;
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
@@ -211,6 +220,10 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||
// engine has actually caught up to the new position.
|
||||
let seekTarget: number | null = null;
|
||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekFallbackTrackId: string | null = null;
|
||||
let seekFallbackRestartAt = 0;
|
||||
|
||||
// Guard against rapid double-click play/pause sending two state transitions
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
@@ -322,6 +335,25 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||
}
|
||||
|
||||
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
|
||||
try {
|
||||
const res = await invoke<{ path: string; size: number } | null>(
|
||||
'promote_stream_cache_to_hot_cache',
|
||||
{
|
||||
trackId: track.id,
|
||||
serverId,
|
||||
url: buildStreamUrl(track.id),
|
||||
suffix: track.suffix || 'mp3',
|
||||
customDir,
|
||||
},
|
||||
);
|
||||
if (!res || !res.path) return;
|
||||
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0);
|
||||
} catch {
|
||||
// best-effort promotion; normal hot-cache prefetch remains fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -378,22 +410,38 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-buffer / pre-chain next track based on preload mode.
|
||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled } = useAuthStore.getState();
|
||||
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
|
||||
const {
|
||||
gaplessEnabled,
|
||||
preloadMode,
|
||||
preloadCustomSeconds,
|
||||
hotCacheEnabled,
|
||||
crossfadeEnabled,
|
||||
crossfadeSecs,
|
||||
} = useAuthStore.getState();
|
||||
const remaining = dur - current_time;
|
||||
|
||||
// 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' && (
|
||||
// Even with preload mode OFF, crossfade needs the next track bytes ready before
|
||||
// we enter the fade window to avoid a hard gap after track boundary.
|
||||
const shouldBytePreloadFromMode = preloadMode !== 'off' && (
|
||||
preloadMode === 'early'
|
||||
? current_time >= 5
|
||||
: preloadMode === 'custom'
|
||||
? remaining < preloadCustomSeconds && remaining > 0
|
||||
: remaining < 30 && remaining > 0 // balanced (default)
|
||||
);
|
||||
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
|
||||
const shouldBytePreloadForCrossfade =
|
||||
!gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
|
||||
const shouldBytePreload = !hotCacheEnabled && (
|
||||
shouldBytePreloadFromMode ||
|
||||
shouldBytePreloadForCrossfade
|
||||
);
|
||||
|
||||
if (shouldChainGapless || shouldBytePreload) {
|
||||
if (shouldChainGapless || shouldBytePreload || gaplessEnabled) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
@@ -401,12 +449,39 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||
// the boundary. Start earlier for larger files / slower conservative link.
|
||||
const estBytes = (() => {
|
||||
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||||
return nextTrack.size;
|
||||
}
|
||||
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||||
? nextTrack.bitRate
|
||||
: 320;
|
||||
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||||
})();
|
||||
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||||
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||||
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||||
const shouldBytePreloadForGaplessBackup =
|
||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||
|
||||
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) {
|
||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
||||
bytePreloadingId = nextTrack.id;
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
nextUrl,
|
||||
shouldBytePreload,
|
||||
shouldBytePreloadForGaplessBackup,
|
||||
remaining,
|
||||
gaplessEnabled,
|
||||
});
|
||||
}
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -569,6 +644,20 @@ export function initAudioListeners(): () => void {
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
listen<string>('audio:preload-ready', ({ payload }) => {
|
||||
const tid = streamUrlTrackId(payload);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-ready]', {
|
||||
payload,
|
||||
parsedTrackId: tid,
|
||||
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
|
||||
});
|
||||
}
|
||||
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
|
||||
else if (import.meta.env.DEV) {
|
||||
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
@@ -595,15 +684,17 @@ export function initAudioListeners(): () => void {
|
||||
// Whenever the current track or playback state changes, push updates to the
|
||||
// Rust souvlaki MediaControls so the OS media overlay stays accurate.
|
||||
let prevTrackId: string | null = null;
|
||||
let prevRadioId: string | null = null;
|
||||
let prevIsPlaying: boolean | null = null;
|
||||
let lastMprisPositionUpdate = 0;
|
||||
|
||||
const unsubMpris = usePlayerStore.subscribe((state) => {
|
||||
const { currentTrack, isPlaying, currentTime } = state;
|
||||
const { currentTrack, currentRadio, isPlaying, currentTime } = state;
|
||||
|
||||
// Update metadata when track changes
|
||||
if (currentTrack && currentTrack.id !== prevTrackId) {
|
||||
prevTrackId = currentTrack.id;
|
||||
prevRadioId = null;
|
||||
const coverUrl = currentTrack.coverArt
|
||||
? buildCoverArtUrl(currentTrack.coverArt, 512)
|
||||
: undefined;
|
||||
@@ -616,6 +707,20 @@ export function initAudioListeners(): () => void {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Update metadata when a radio station starts (initial push — station name as title).
|
||||
// ICY StreamTitle updates are forwarded by the radio:metadata listener below.
|
||||
if (currentRadio && currentRadio.id !== prevRadioId) {
|
||||
prevRadioId = currentRadio.id;
|
||||
prevTrackId = null;
|
||||
invoke('mpris_set_metadata', {
|
||||
title: currentRadio.name,
|
||||
artist: null,
|
||||
album: null,
|
||||
coverUrl: null,
|
||||
durationSecs: null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Update playback state on play/pause change
|
||||
const playbackChanged = isPlaying !== prevIsPlaying;
|
||||
if (playbackChanged) {
|
||||
@@ -631,7 +736,8 @@ export function initAudioListeners(): () => void {
|
||||
|
||||
// Keep position in sync while playing — update every ~500 ms so Plasma
|
||||
// always shows the correct time without interpolation gaps.
|
||||
if (isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
|
||||
// Radio streams have no meaningful position, so skip for radio.
|
||||
if (!currentRadio && isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
invoke('mpris_set_playback', {
|
||||
playing: true,
|
||||
@@ -640,22 +746,58 @@ export function initAudioListeners(): () => void {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Radio ICY StreamTitle → MPRIS ─────────────────────────────────────────
|
||||
// The Rust download task emits "radio:metadata" with { title, is_ad } every
|
||||
// time an ICY metadata block changes (typically every 8–32 KB of audio).
|
||||
// Forward each update to mpris_set_metadata so the OS now-playing overlay
|
||||
// stays in sync while the stream is live.
|
||||
const radioMetaUnlisten = listen<{ title: string; is_ad: boolean }>('radio:metadata', ({ payload }) => {
|
||||
const { currentRadio } = usePlayerStore.getState();
|
||||
if (!currentRadio) return; // guard: only forward during active radio session
|
||||
if (payload.is_ad) return; // skip CDN-injected ad metadata
|
||||
|
||||
// Parse "Artist - Title" convention used by most ICY streams.
|
||||
const sep = payload.title.indexOf(' - ');
|
||||
const artist = sep !== -1 ? payload.title.slice(0, sep).trim() : null;
|
||||
const title = sep !== -1 ? payload.title.slice(sep + 3).trim() : payload.title;
|
||||
|
||||
invoke('mpris_set_metadata', {
|
||||
title: title || currentRadio.name,
|
||||
artist: artist || currentRadio.name,
|
||||
album: null,
|
||||
coverUrl: null,
|
||||
durationSecs: null,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// ── Discord Rich Presence sync ────────────────────────────────────────────
|
||||
// Updates on track change or play/pause toggle. No per-tick updates needed —
|
||||
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
|
||||
let discordPrevTrackId: string | null = null;
|
||||
let discordPrevIsPlaying: boolean | null = null;
|
||||
let discordPrevFetchCovers: boolean | null = null;
|
||||
let discordPrevTemplateDetails: string | null = null;
|
||||
let discordPrevTemplateState: string | null = null;
|
||||
let discordPrevTemplateLargeText: string | null = null;
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
|
||||
const { discordRichPresence, enableAppleMusicCoversDiscord } = useAuthStore.getState();
|
||||
const {
|
||||
discordRichPresence,
|
||||
enableAppleMusicCoversDiscord,
|
||||
discordTemplateDetails,
|
||||
discordTemplateState,
|
||||
discordTemplateLargeText,
|
||||
} = useAuthStore.getState();
|
||||
|
||||
if (!discordRichPresence || !currentTrack) {
|
||||
if (discordPrevTrackId !== null) {
|
||||
discordPrevTrackId = null;
|
||||
discordPrevIsPlaying = null;
|
||||
discordPrevFetchCovers = null;
|
||||
discordPrevTemplateDetails = null;
|
||||
discordPrevTemplateState = null;
|
||||
discordPrevTemplateLargeText = null;
|
||||
invoke('discord_clear_presence').catch(() => {});
|
||||
}
|
||||
return;
|
||||
@@ -664,24 +806,31 @@ export function initAudioListeners(): () => void {
|
||||
const trackChanged = currentTrack.id !== discordPrevTrackId;
|
||||
const playingChanged = isPlaying !== discordPrevIsPlaying;
|
||||
const coversSettingChanged = enableAppleMusicCoversDiscord !== discordPrevFetchCovers;
|
||||
if (!trackChanged && !playingChanged && !coversSettingChanged) return;
|
||||
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
|
||||
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
|
||||
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
|
||||
if (!trackChanged && !playingChanged && !coversSettingChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return;
|
||||
|
||||
discordPrevTrackId = currentTrack.id;
|
||||
discordPrevIsPlaying = isPlaying;
|
||||
discordPrevFetchCovers = enableAppleMusicCoversDiscord;
|
||||
discordPrevTemplateDetails = discordTemplateDetails;
|
||||
discordPrevTemplateState = discordTemplateState;
|
||||
discordPrevTemplateLargeText = discordTemplateLargeText;
|
||||
|
||||
invoke('discord_update_presence', {
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist ?? 'Unknown Artist',
|
||||
album: currentTrack.album ?? null,
|
||||
isPlaying,
|
||||
// Pass elapsed when playing so Discord shows a live running timer.
|
||||
// Pass null when paused — Discord clears the timer.
|
||||
elapsedSecs: isPlaying ? currentTime : null,
|
||||
// coverArtUrl is intentionally not passed — Subsonic URLs require auth.
|
||||
// iTunes cover fetching is only done when explicitly opted in.
|
||||
coverArtUrl: null,
|
||||
fetchItunesCovers: enableAppleMusicCoversDiscord,
|
||||
detailsTemplate: discordTemplateDetails,
|
||||
stateTemplate: discordTemplateState,
|
||||
largeTextTemplate: discordTemplateLargeText,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -694,6 +843,7 @@ export function initAudioListeners(): () => void {
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
radioMetaUnlisten.then(unlisten => unlisten());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -704,6 +854,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
currentRadio: null,
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
@@ -815,7 +967,15 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
|
||||
set({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
currentRadio: null,
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
});
|
||||
},
|
||||
|
||||
// ── playRadio ────────────────────────────────────────────────────────────
|
||||
@@ -846,6 +1006,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({
|
||||
currentRadio: station,
|
||||
currentTrack: null,
|
||||
currentPlaybackSource: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: true,
|
||||
@@ -868,6 +1029,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
if (seekFallbackRetryTimer) { clearTimeout(seekFallbackRetryTimer); seekFallbackRetryTimer = null; }
|
||||
seekFallbackTrackId = null;
|
||||
seekFallbackRestartAt = 0;
|
||||
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||
@@ -879,9 +1043,29 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const prevTrack = state.currentTrack;
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
|
||||
const authState = useAuthStore.getState();
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === track.id;
|
||||
const playbackSourceHint = getPlaybackSourceKind(
|
||||
track.id,
|
||||
authState.activeServerId ?? '',
|
||||
keepPreloadHint ? track.id : null,
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][playTrack-source]', {
|
||||
trackId: track.id,
|
||||
resolvedUrl: url,
|
||||
preloadedTrackId,
|
||||
keepPreloadHint,
|
||||
playbackSourceHint,
|
||||
});
|
||||
}
|
||||
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||
set({
|
||||
@@ -895,11 +1079,23 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||
});
|
||||
|
||||
const authState = useAuthStore.getState();
|
||||
if (
|
||||
prevTrack
|
||||
&& prevTrack.id !== track.id
|
||||
&& authState.hotCacheEnabled
|
||||
&& authState.activeServerId
|
||||
) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
prevTrack,
|
||||
authState.activeServerId,
|
||||
authState.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
setDeferHotCachePrefetch(true);
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
@@ -914,16 +1110,23 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
@@ -942,6 +1145,22 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
||||
},
|
||||
|
||||
reseedQueueForInstantMix: (track) => {
|
||||
const s = get();
|
||||
if (s.currentTrack?.id !== track.id) {
|
||||
get().playTrack(track, [track]);
|
||||
return;
|
||||
}
|
||||
const wasPlaying = s.isPlaying;
|
||||
set({
|
||||
queue: [track],
|
||||
queueIndex: 0,
|
||||
currentTrack: track,
|
||||
});
|
||||
syncQueueToServer([track], track, s.currentTime);
|
||||
if (!wasPlaying) get().resume();
|
||||
},
|
||||
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
if (get().currentRadio) {
|
||||
@@ -1207,7 +1426,32 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
seekTarget = time;
|
||||
invoke('audio_seek', { seconds: time }).catch(console.error);
|
||||
invoke('audio_seek', { seconds: time }).catch((err: unknown) => {
|
||||
const msg = String(err ?? '');
|
||||
if (!msg.includes('not seekable')) {
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
// Streaming-start path can be non-seekable until the download finishes.
|
||||
// Fallback: at most one restart burst per track, then keep only the latest retry seek.
|
||||
const s = get();
|
||||
if (!s.currentTrack) return;
|
||||
const now = Date.now();
|
||||
const sameBurst =
|
||||
seekFallbackTrackId === s.currentTrack.id
|
||||
&& now - seekFallbackRestartAt < 600;
|
||||
if (!sameBurst) {
|
||||
seekFallbackTrackId = s.currentTrack.id;
|
||||
seekFallbackRestartAt = now;
|
||||
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
||||
s.playTrack(s.currentTrack, s.queue, true);
|
||||
}
|
||||
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
|
||||
seekFallbackRetryTimer = setTimeout(() => {
|
||||
seekFallbackRetryTimer = null;
|
||||
invoke('audio_seek', { seconds: time }).catch(() => {});
|
||||
}, 220);
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'newReleases', visible: true },
|
||||
{ id: 'allAlbums', visible: true },
|
||||
{ id: 'randomPicker', visible: true },
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'randomAlbums', visible: true },
|
||||
{ id: 'artists', visible: true },
|
||||
{ id: 'genres', visible: true },
|
||||
{ id: 'favorites', visible: true },
|
||||
@@ -20,6 +22,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
{ id: 'radio', visible: true },
|
||||
{ id: 'folderBrowser', visible: false },
|
||||
{ id: 'deviceSync', visible: false },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
@@ -48,11 +51,11 @@ export const useSidebarStore = create<SidebarStore>()(
|
||||
name: 'psysonic_sidebar',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
const known = new Set(state.items.map(i => i.id));
|
||||
// Sanitize: remove any null/corrupted entries that may have been persisted
|
||||
const safe = (state.items ?? []).filter((i): i is SidebarItemConfig => i != null && typeof i.id === 'string');
|
||||
const known = new Set(safe.map(i => i.id));
|
||||
const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id));
|
||||
if (missing.length > 0) {
|
||||
state.items = [...state.items, ...missing];
|
||||
}
|
||||
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,6 +16,12 @@ interface ThemeState {
|
||||
setTimeDayStart: (v: string) => void;
|
||||
timeNightStart: string;
|
||||
setTimeNightStart: (v: string) => void;
|
||||
enableCoverArtBackground: boolean;
|
||||
setEnableCoverArtBackground: (v: boolean) => void;
|
||||
enablePlaylistCoverPhoto: boolean;
|
||||
setEnablePlaylistCoverPhoto: (v: boolean) => void;
|
||||
showBitrate: boolean;
|
||||
setShowBitrate: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
||||
@@ -47,6 +53,12 @@ export const useThemeStore = create<ThemeState>()(
|
||||
setTimeDayStart: (v) => set({ timeDayStart: v }),
|
||||
timeNightStart: '19:00',
|
||||
setTimeNightStart: (v) => set({ timeNightStart: v }),
|
||||
enableCoverArtBackground: true,
|
||||
setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }),
|
||||
enablePlaylistCoverPhoto: true,
|
||||
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
|
||||
showBitrate: true,
|
||||
setShowBitrate: (v) => set({ showBitrate: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
|
||||
+782
-5
@@ -921,6 +921,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.album-detail-header {
|
||||
@@ -1399,6 +1400,10 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tracklist-header-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -1490,14 +1495,18 @@
|
||||
}
|
||||
|
||||
/* ── Column visibility picker ── */
|
||||
.tracklist-col-picker-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--space-6);
|
||||
margin-bottom: 4px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.tracklist-col-picker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tracklist-col-picker-btn {
|
||||
@@ -1577,6 +1586,33 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tracklist-col-picker-divider {
|
||||
height: 1px;
|
||||
background: var(--ctp-surface1);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tracklist-col-picker-reset {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tracklist-col-picker-reset:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Delete button in playlist row */
|
||||
.playlist-row-delete-cell {
|
||||
display: flex;
|
||||
@@ -1984,6 +2020,16 @@
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Modal CSV Import - siempre centrado */
|
||||
.modal-overlay--csv {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
@@ -4090,6 +4136,24 @@ html.no-compositing .fs-seekbar-played {
|
||||
}
|
||||
|
||||
/* ─ Connection Indicator ─ */
|
||||
.connection-indicator-host {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-indicator-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, 85vw);
|
||||
}
|
||||
|
||||
.connection-indicator-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
transition: transform var(--transition-fast, 0.15s ease);
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7558,3 +7622,716 @@ html.no-compositing .fs-seekbar-played {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
/* ─── Device Sync ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.device-sync-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 24px 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
|
||||
.device-sync-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-header-title h1 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Config Row (Template + Drive Layout) ── */
|
||||
|
||||
.device-sync-config-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-sync-template-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.device-sync-target-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end; /* Flush right alignment */
|
||||
flex: 1; /* allow block expansion */
|
||||
}
|
||||
|
||||
.device-sync-folder-path {
|
||||
max-width: 260px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 5px 8px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Drive layout ── */
|
||||
|
||||
.device-sync-drive-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-sync-drive-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-sync-drive-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.device-sync-drive-icon {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-drive-select {
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
font-size: 0.95rem;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-sync-drive-controls .btn-ghost {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-no-drives {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--warning, #f59e0b);
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.device-sync-label-inline {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-sync-template-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-sync-template-preset-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.device-sync-template-preset-btn:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-template-preset-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-template-input-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-sync-template-input-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-sync-template-input {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
height: 40px;
|
||||
padding: 0 32px 0 10px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.device-sync-template-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.device-sync-template-clear:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-template-tokens {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-sync-template-token {
|
||||
padding: 2px 7px;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.device-sync-template-token:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--ctp-surface0));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.device-sync-template-preview {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-left: 2px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ── Main area (device panel + browser) ── */
|
||||
|
||||
.device-sync-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Device panel ── */
|
||||
|
||||
.device-sync-device-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
height: 52px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-panel-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.device-sync-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.device-sync-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 20px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Device list ── */
|
||||
|
||||
.device-sync-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-sync-list-col-name {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.device-sync-list-col-type {
|
||||
width: 70px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-device-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-sync-device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-device-row:last-child { border-bottom: none; }
|
||||
.device-sync-source-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.device-sync-source-row.deletion, .device-sync-row.deletion {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.device-sync-row-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-device-row.checked { background: var(--accent-dim); }
|
||||
|
||||
.device-sync-source-type {
|
||||
width: 70px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Status summary badges ── */
|
||||
|
||||
.device-sync-status-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.device-sync-badge.synced {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
}
|
||||
|
||||
.device-sync-badge.pending {
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent);
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.device-sync-badge.deletion {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* ── Status column ── */
|
||||
|
||||
.device-sync-list-col-status {
|
||||
width: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-sync-list-col-actions {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Status icons per row ── */
|
||||
|
||||
.device-sync-status-icon {
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-status-icon.synced { color: var(--success, #4ade80); }
|
||||
.device-sync-status-icon.pending { color: var(--warning, #f59e0b); }
|
||||
.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); }
|
||||
|
||||
/* ── Per-row action buttons ── */
|
||||
|
||||
.device-sync-row-actions {
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.device-sync-device-row:hover .device-sync-action-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.device-sync-action-btn.danger {
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
.device-sync-action-btn.danger:hover {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-sync-action-btn.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.device-sync-action-btn.muted:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-sync-action-btn.undo {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.device-sync-action-btn.undo:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ── Row status variants ── */
|
||||
|
||||
.device-sync-device-row.synced {}
|
||||
.device-sync-device-row.pending {}
|
||||
.device-sync-device-row.deletion {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.device-sync-device-row.deletion .device-sync-row-name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* ── Background sync progress (non-blocking) ── */
|
||||
|
||||
.device-sync-bg-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress.done {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
|
||||
.color-success { color: var(--success, #4ade80); }
|
||||
|
||||
/* ── Browser panel ── */
|
||||
|
||||
.device-sync-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-tabs {
|
||||
display: flex;
|
||||
height: 52px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.device-sync-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.device-sync-tab.active { color: var(--accent); border-bottom: 2px solid var(--accent); }
|
||||
|
||||
.device-sync-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-search-wrap .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-sync-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.device-sync-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-sync-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-sync-browser-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-browser-row:hover { background: var(--bg-hover); }
|
||||
.device-sync-browser-row.selected { background: var(--accent-dim); }
|
||||
.device-sync-browser-row.indent { padding-left: 36px; background: var(--bg-secondary); }
|
||||
.device-sync-browser-row.indent:hover { background: var(--bg-hover); }
|
||||
.device-sync-browser-row.indent.selected { background: var(--accent-dim); }
|
||||
|
||||
.device-sync-artist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.device-sync-expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.device-sync-expand-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.device-sync-row-check { color: var(--accent); display: flex; flex-shrink: 0; }
|
||||
.device-sync-row-circle {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-sync-row-name { flex: 1; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }
|
||||
|
||||
+26
-1
@@ -309,7 +309,7 @@
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dropdown, rgba(255, 255, 255, 0.12));
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 8px 24px var(--shadow-dropdown, rgba(0, 0, 0, 0.45));
|
||||
box-sizing: border-box;
|
||||
overflow-y: visible;
|
||||
@@ -912,6 +912,13 @@
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Sidebar device-sync queue ─── */
|
||||
.sidebar-sync-queue {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 10%, var(--bg-sidebar));
|
||||
border-color: color-mix(in srgb, var(--success, #4ade80) 25%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -1586,6 +1593,24 @@
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.queue-current-tech-source {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 0;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.queue-current-tech-main {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
|
||||
+617
-7
@@ -19,6 +19,9 @@
|
||||
@import '@fontsource-variable/plus-jakarta-sans';
|
||||
@import '@fontsource-variable/plus-jakarta-sans/wght-italic.css';
|
||||
@import '@fontsource-variable/lexend';
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource-variable/jetbrains-mono';
|
||||
@import '@fontsource-variable/jetbrains-mono/wght-italic.css';
|
||||
|
||||
/* ─── Catppuccin Mocha Variables ─── */
|
||||
:root,
|
||||
@@ -223,6 +226,35 @@
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Latte light-theme component overrides ─── */
|
||||
[data-theme='latte'] {
|
||||
--border-dropdown: var(--border);
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='latte'] .album-detail-back {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
[data-theme='latte'] .album-detail-back:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme='latte'] .np-artist-album { color: var(--text-secondary); }
|
||||
[data-theme='latte'] .np-badge { background: var(--bg-hover); color: var(--text-primary); }
|
||||
[data-theme='latte'] .np-star-btn { color: var(--text-muted); }
|
||||
[data-theme='latte'] .np-tag-cloud-header { color: var(--text-muted); }
|
||||
[data-theme='latte'] .np-tag { background: var(--bg-hover); color: var(--text-secondary); }
|
||||
[data-theme='latte'] .np-album-track:hover { background: var(--bg-hover); }
|
||||
[data-theme='latte'] .np-album-track.active { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
[data-theme='latte'] .np-album-track-num { color: var(--text-muted); }
|
||||
[data-theme='latte'] .np-album-track-title { color: var(--text-primary); }
|
||||
[data-theme='latte'] .np-album-track.active .np-album-track-title { color: var(--accent); }
|
||||
[data-theme='latte'] .np-album-track-dur { color: var(--text-muted); }
|
||||
[data-theme='latte'] .np-link:hover { color: var(--accent); }
|
||||
[data-theme='latte'] .np-radio-time { color: var(--text-muted); }
|
||||
[data-theme='latte'] .np-bio-text { color: var(--text-secondary); }
|
||||
|
||||
/* ─── Nord – Polar Night (Dark) ─── */
|
||||
[data-theme='nord'] {
|
||||
color-scheme: dark;
|
||||
@@ -1550,11 +1582,11 @@
|
||||
--ctp-surface0: #161616;
|
||||
--ctp-surface1: #202020;
|
||||
--ctp-surface2: #2c2c2c;
|
||||
--ctp-overlay0: #484848;
|
||||
--ctp-overlay1: #707070;
|
||||
--ctp-overlay2: #989898;
|
||||
--ctp-text: #e8e8e8;
|
||||
--ctp-subtext1: #c0c0c0;
|
||||
--ctp-overlay0: #707070;
|
||||
--ctp-overlay1: #909090;
|
||||
--ctp-overlay2: #b0b0b0;
|
||||
--ctp-text: #d8d8d8;
|
||||
--ctp-subtext1: #b8b8b8;
|
||||
--ctp-subtext0: #888888;
|
||||
|
||||
/* GTA palette */
|
||||
@@ -1594,10 +1626,10 @@
|
||||
--volume-accent: #ffd700;
|
||||
/* wanted-gold for volume knob */
|
||||
|
||||
--text-primary: #e8e8e8;
|
||||
--text-primary: #d8d8d8;
|
||||
--text-secondary: #ffd700;
|
||||
/* wanted-level gold — the key differentiator */
|
||||
--text-muted: #484848;
|
||||
--text-muted: #707070;
|
||||
--border: #242424;
|
||||
--border-subtle: #161616;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.9);
|
||||
@@ -3711,6 +3743,12 @@ body.psy-dragging * {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* ─── Input ─── */
|
||||
.input {
|
||||
width: 100%;
|
||||
@@ -3750,6 +3788,30 @@ select.input.input:focus {
|
||||
/* Keep arrow on focus */
|
||||
}
|
||||
|
||||
/* ─── Input Search ─── */
|
||||
.input-search {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-left: 36px;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-overlay0);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-search:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ─── Card ─── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
@@ -6369,6 +6431,16 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--font-display: 'Lexend Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='geist'] {
|
||||
--font-sans: 'Geist Variable', system-ui, sans-serif;
|
||||
--font-display: 'Geist Variable', sans-serif;
|
||||
}
|
||||
|
||||
[data-font='jetbrains-mono'] {
|
||||
--font-sans: 'JetBrains Mono Variable', monospace;
|
||||
--font-display: 'JetBrains Mono Variable', monospace;
|
||||
}
|
||||
|
||||
/* ─── Equalizer bars animation ─── */
|
||||
.eq-bar {
|
||||
display: inline-block;
|
||||
@@ -14423,3 +14495,541 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--danger: #d55e00;
|
||||
--volume-accent: #ffd700;
|
||||
}
|
||||
|
||||
/* ─── COMMUNITY THEMES ─── */
|
||||
|
||||
/* ─── AMOLED Black Pure ─── */
|
||||
[data-theme='amoled-black-pure'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds — absolute black everywhere */
|
||||
--ctp-crust: #000000;
|
||||
--ctp-mantle: #000000;
|
||||
--ctp-base: #000000;
|
||||
--ctp-surface0: #000000;
|
||||
--ctp-surface1: #050505;
|
||||
--ctp-surface2: #0a0a0a;
|
||||
|
||||
/* Overlays / muted text — raised for contrast */
|
||||
--ctp-overlay0: #555555;
|
||||
--ctp-overlay1: #707070;
|
||||
--ctp-overlay2: #8a8a8a;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #ffffff;
|
||||
--ctp-subtext1: #e0e0e0;
|
||||
--ctp-subtext0: #bbbbbb;
|
||||
|
||||
/* Accent — pure white */
|
||||
--ctp-teal: #ffffff;
|
||||
--ctp-sky: #ffffff;
|
||||
--ctp-sapphire: #e0e0e0;
|
||||
--ctp-blue: #cccccc;
|
||||
--ctp-lavender: #ffffff;
|
||||
--ctp-mauve: #e0e0e0;
|
||||
--ctp-pink: #cccccc;
|
||||
--ctp-flamingo: #ffffff;
|
||||
--ctp-rosewater: #e0e0e0;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #f87171;
|
||||
--ctp-maroon: #fb923c;
|
||||
--ctp-peach: #fb923c;
|
||||
--ctp-yellow: #fbbf24;
|
||||
--ctp-green: #4ade80;
|
||||
|
||||
/* UI tokens — all black */
|
||||
--bg-app: #000000;
|
||||
--bg-sidebar: #000000;
|
||||
--bg-card: #000000;
|
||||
--bg-hover: #0a0a0a;
|
||||
--bg-player: #000000;
|
||||
--bg-glass: rgba(0, 0, 0, 0.95);
|
||||
|
||||
--accent: #ffffff;
|
||||
--accent-dim: rgba(255, 255, 255, 0.07);
|
||||
--accent-glow: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-muted: #777777;
|
||||
|
||||
--border: #141414;
|
||||
--border-subtle: #0a0a0a;
|
||||
--border-dropdown: #1f1f1f;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.98);
|
||||
|
||||
--positive: #4ade80;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f87171;
|
||||
}
|
||||
|
||||
/* ─── Monochrome Dark ─── */
|
||||
[data-theme='monochrome'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c0c0c0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds — deep grayscale */
|
||||
--ctp-crust: #0d0d0d;
|
||||
--ctp-mantle: #111111;
|
||||
--ctp-base: #161616;
|
||||
--ctp-surface0: #1c1c1c;
|
||||
--ctp-surface1: #232323;
|
||||
--ctp-surface2: #2a2a2a;
|
||||
|
||||
/* Overlays — medium grays with good contrast */
|
||||
--ctp-overlay0: #555555;
|
||||
--ctp-overlay1: #6e6e6e;
|
||||
--ctp-overlay2: #888888;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #e8e8e8;
|
||||
--ctp-subtext1: #c0c0c0;
|
||||
--ctp-subtext0: #999999;
|
||||
|
||||
/* Accent — silver / light gray */
|
||||
--ctp-teal: #c0c0c0;
|
||||
--ctp-sky: #c0c0c0;
|
||||
--ctp-sapphire: #aaaaaa;
|
||||
--ctp-blue: #999999;
|
||||
--ctp-lavender: #c0c0c0;
|
||||
--ctp-mauve: #aaaaaa;
|
||||
--ctp-pink: #999999;
|
||||
--ctp-flamingo: #c0c0c0;
|
||||
--ctp-rosewater: #aaaaaa;
|
||||
|
||||
/* Semantic — desaturated for monochrome */
|
||||
--ctp-red: #cc6666;
|
||||
--ctp-maroon: #bb7744;
|
||||
--ctp-peach: #bb7744;
|
||||
--ctp-yellow: #bbaa55;
|
||||
--ctp-green: #77aa77;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #161616;
|
||||
--bg-sidebar: #111111;
|
||||
--bg-card: #1c1c1c;
|
||||
--bg-hover: #232323;
|
||||
--bg-player: #111111;
|
||||
--bg-glass: rgba(22, 22, 22, 0.95);
|
||||
|
||||
--accent: #c0c0c0;
|
||||
--accent-dim: rgba(192, 192, 192, 0.07);
|
||||
--accent-glow: rgba(192, 192, 192, 0.12);
|
||||
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-muted: #777777;
|
||||
|
||||
--border: #282828;
|
||||
--border-subtle: #1f1f1f;
|
||||
--border-dropdown: #303030;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.85);
|
||||
|
||||
--positive: #77aa77;
|
||||
--warning: #bbaa55;
|
||||
--danger: #cc6666;
|
||||
}
|
||||
|
||||
/* ─── Amber Night ─── */
|
||||
[data-theme='amber-night'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23d4a96a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #0a0804;
|
||||
--ctp-mantle: #0f0c07;
|
||||
--ctp-base: #1a1410;
|
||||
--ctp-surface0: #201a12;
|
||||
--ctp-surface1: #2a2318;
|
||||
--ctp-surface2: #332b1e;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #5a4a30;
|
||||
--ctp-overlay1: #7a6540;
|
||||
--ctp-overlay2: #9a8055;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #f0e0c8;
|
||||
--ctp-subtext1: #d4b88a;
|
||||
--ctp-subtext0: #b09060;
|
||||
|
||||
/* Golden amber accent */
|
||||
--ctp-teal: #d4a96a;
|
||||
--ctp-sky: #d4a96a;
|
||||
--ctp-sapphire: #c49055;
|
||||
--ctp-blue: #b07840;
|
||||
--ctp-lavender: #d4a96a;
|
||||
--ctp-mauve: #c49055;
|
||||
--ctp-pink: #b07840;
|
||||
--ctp-flamingo: #d4a96a;
|
||||
--ctp-rosewater: #c49055;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e07060;
|
||||
--ctp-maroon: #d08050;
|
||||
--ctp-peach: #d08050;
|
||||
--ctp-yellow: #d4a96a;
|
||||
--ctp-green: #80b870;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #1a1410;
|
||||
--bg-sidebar: #0f0c07;
|
||||
--bg-card: #201a12;
|
||||
--bg-hover: #2a2318;
|
||||
--bg-player: #0f0c07;
|
||||
--bg-glass: rgba(26, 20, 16, 0.95);
|
||||
|
||||
--accent: #d4a96a;
|
||||
--accent-dim: rgba(212, 169, 106, 0.08);
|
||||
--accent-glow: rgba(212, 169, 106, 0.15);
|
||||
|
||||
--text-primary: #f0e0c8;
|
||||
--text-secondary: #b09060;
|
||||
--text-muted: #7a6040;
|
||||
|
||||
--border: #2a2318;
|
||||
--border-subtle: #201a12;
|
||||
--border-dropdown: #332b1e;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #80b870;
|
||||
--warning: #d4a96a;
|
||||
--danger: #e07060;
|
||||
}
|
||||
|
||||
/* ─── Phosphor Green ─── */
|
||||
[data-theme='phosphor-green'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%234ade80%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #050e05;
|
||||
--ctp-mantle: #070f07;
|
||||
--ctp-base: #0d1a0d;
|
||||
--ctp-surface0: #111f11;
|
||||
--ctp-surface1: #162616;
|
||||
--ctp-surface2: #1c2e1c;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #2d5a2d;
|
||||
--ctp-overlay1: #3d7a3d;
|
||||
--ctp-overlay2: #4d9a4d;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #d0f0d0;
|
||||
--ctp-subtext1: #a0d0a0;
|
||||
--ctp-subtext0: #70aa70;
|
||||
|
||||
/* Phosphor green accent */
|
||||
--ctp-teal: #4ade80;
|
||||
--ctp-sky: #4ade80;
|
||||
--ctp-sapphire: #38c070;
|
||||
--ctp-blue: #2aa060;
|
||||
--ctp-lavender: #4ade80;
|
||||
--ctp-mauve: #38c070;
|
||||
--ctp-pink: #2aa060;
|
||||
--ctp-flamingo: #4ade80;
|
||||
--ctp-rosewater: #38c070;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e06060;
|
||||
--ctp-maroon: #c07840;
|
||||
--ctp-peach: #c07840;
|
||||
--ctp-yellow: #b0b050;
|
||||
--ctp-green: #4ade80;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #0d1a0d;
|
||||
--bg-sidebar: #070f07;
|
||||
--bg-card: #111f11;
|
||||
--bg-hover: #162616;
|
||||
--bg-player: #070f07;
|
||||
--bg-glass: rgba(13, 26, 13, 0.95);
|
||||
|
||||
--accent: #4ade80;
|
||||
--accent-dim: rgba(74, 222, 128, 0.07);
|
||||
--accent-glow: rgba(74, 222, 128, 0.14);
|
||||
|
||||
--text-primary: #d0f0d0;
|
||||
--text-secondary: #70aa70;
|
||||
--text-muted: #3d6a3d;
|
||||
|
||||
--border: #1c2e1c;
|
||||
--border-subtle: #111f11;
|
||||
--border-dropdown: #223022;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.88);
|
||||
|
||||
--positive: #4ade80;
|
||||
--warning: #b0b050;
|
||||
--danger: #e06060;
|
||||
}
|
||||
|
||||
/* ─── Midnight Blue ─── */
|
||||
[data-theme='midnight-blue'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2360a5fa%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #07090f;
|
||||
--ctp-mantle: #090c14;
|
||||
--ctp-base: #0d1420;
|
||||
--ctp-surface0: #111a28;
|
||||
--ctp-surface1: #162030;
|
||||
--ctp-surface2: #1c2838;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #2a3d5a;
|
||||
--ctp-overlay1: #3a507a;
|
||||
--ctp-overlay2: #4a659a;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #ccd8f0;
|
||||
--ctp-subtext1: #9ab0d8;
|
||||
--ctp-subtext0: #6888b0;
|
||||
|
||||
/* Light blue accent */
|
||||
--ctp-teal: #60a5fa;
|
||||
--ctp-sky: #60a5fa;
|
||||
--ctp-sapphire: #4a90e8;
|
||||
--ctp-blue: #3878d0;
|
||||
--ctp-lavender: #60a5fa;
|
||||
--ctp-mauve: #4a90e8;
|
||||
--ctp-pink: #3878d0;
|
||||
--ctp-flamingo: #60a5fa;
|
||||
--ctp-rosewater: #4a90e8;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e07070;
|
||||
--ctp-maroon: #d09050;
|
||||
--ctp-peach: #d09050;
|
||||
--ctp-yellow: #c8b850;
|
||||
--ctp-green: #60c880;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #0d1420;
|
||||
--bg-sidebar: #090c14;
|
||||
--bg-card: #111a28;
|
||||
--bg-hover: #162030;
|
||||
--bg-player: #090c14;
|
||||
--bg-glass: rgba(13, 20, 32, 0.95);
|
||||
|
||||
--accent: #60a5fa;
|
||||
--accent-dim: rgba(96, 165, 250, 0.07);
|
||||
--accent-glow: rgba(96, 165, 250, 0.14);
|
||||
|
||||
--text-primary: #ccd8f0;
|
||||
--text-secondary: #6888b0;
|
||||
--text-muted: #3a5070;
|
||||
|
||||
--border: #1c2838;
|
||||
--border-subtle: #111a28;
|
||||
--border-dropdown: #223040;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #60c880;
|
||||
--warning: #c8b850;
|
||||
--danger: #e07070;
|
||||
}
|
||||
|
||||
/* ─── Rose Dark ─── */
|
||||
[data-theme='rose-dark'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23f472b6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #0e080b;
|
||||
--ctp-mantle: #120a0e;
|
||||
--ctp-base: #1a0d14;
|
||||
--ctp-surface0: #20111a;
|
||||
--ctp-surface1: #281520;
|
||||
--ctp-surface2: #301a27;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #5a2a40;
|
||||
--ctp-overlay1: #7a3a58;
|
||||
--ctp-overlay2: #9a5070;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #f0d0e0;
|
||||
--ctp-subtext1: #d8a0c0;
|
||||
--ctp-subtext0: #b07090;
|
||||
|
||||
/* Pink accent */
|
||||
--ctp-teal: #f472b6;
|
||||
--ctp-sky: #f472b6;
|
||||
--ctp-sapphire: #e055a0;
|
||||
--ctp-blue: #c8408a;
|
||||
--ctp-lavender: #f472b6;
|
||||
--ctp-mauve: #e055a0;
|
||||
--ctp-pink: #c8408a;
|
||||
--ctp-flamingo: #f472b6;
|
||||
--ctp-rosewater: #e055a0;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e06070;
|
||||
--ctp-maroon: #d07850;
|
||||
--ctp-peach: #d07850;
|
||||
--ctp-yellow: #c8a850;
|
||||
--ctp-green: #70b880;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #1a0d14;
|
||||
--bg-sidebar: #120a0e;
|
||||
--bg-card: #20111a;
|
||||
--bg-hover: #281520;
|
||||
--bg-player: #120a0e;
|
||||
--bg-glass: rgba(26, 13, 20, 0.95);
|
||||
|
||||
--accent: #f472b6;
|
||||
--accent-dim: rgba(244, 114, 182, 0.07);
|
||||
--accent-glow: rgba(244, 114, 182, 0.14);
|
||||
|
||||
--text-primary: #f0d0e0;
|
||||
--text-secondary: #b07090;
|
||||
--text-muted: #704858;
|
||||
|
||||
--border: #281520;
|
||||
--border-subtle: #20111a;
|
||||
--border-dropdown: #341a28;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #70b880;
|
||||
--warning: #c8a850;
|
||||
--danger: #e06070;
|
||||
}
|
||||
|
||||
/* ─── Sepia Dark ─── */
|
||||
[data-theme='sepia-dark'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8b89a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #0e0c09;
|
||||
--ctp-mantle: #13100c;
|
||||
--ctp-base: #1e1a14;
|
||||
--ctp-surface0: #252018;
|
||||
--ctp-surface1: #2e281e;
|
||||
--ctp-surface2: #373025;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #605545;
|
||||
--ctp-overlay1: #806e5a;
|
||||
--ctp-overlay2: #9a8870;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #ede0cc;
|
||||
--ctp-subtext1: #d0c0a8;
|
||||
--ctp-subtext0: #b0a080;
|
||||
|
||||
/* Cream accent */
|
||||
--ctp-teal: #c8b89a;
|
||||
--ctp-sky: #c8b89a;
|
||||
--ctp-sapphire: #b8a080;
|
||||
--ctp-blue: #a08868;
|
||||
--ctp-lavender: #c8b89a;
|
||||
--ctp-mauve: #b8a080;
|
||||
--ctp-pink: #a08868;
|
||||
--ctp-flamingo: #c8b89a;
|
||||
--ctp-rosewater: #b8a080;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #c87060;
|
||||
--ctp-maroon: #c08050;
|
||||
--ctp-peach: #c08050;
|
||||
--ctp-yellow: #b8a050;
|
||||
--ctp-green: #80a870;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #1e1a14;
|
||||
--bg-sidebar: #13100c;
|
||||
--bg-card: #252018;
|
||||
--bg-hover: #2e281e;
|
||||
--bg-player: #13100c;
|
||||
--bg-glass: rgba(30, 26, 20, 0.95);
|
||||
|
||||
--accent: #c8b89a;
|
||||
--accent-dim: rgba(200, 184, 154, 0.07);
|
||||
--accent-glow: rgba(200, 184, 154, 0.13);
|
||||
|
||||
--text-primary: #ede0cc;
|
||||
--text-secondary: #b0a080;
|
||||
--text-muted: #706050;
|
||||
|
||||
--border: #2e281e;
|
||||
--border-subtle: #252018;
|
||||
--border-dropdown: #3a3025;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.88);
|
||||
|
||||
--positive: #80a870;
|
||||
--warning: #b8a050;
|
||||
--danger: #c87060;
|
||||
}
|
||||
|
||||
/* ─── Ice Blue ─── */
|
||||
[data-theme='ice-blue'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%237dd3e8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Backgrounds */
|
||||
--ctp-crust: #07101a;
|
||||
--ctp-mantle: #09141e;
|
||||
--ctp-base: #0e1d28;
|
||||
--ctp-surface0: #132430;
|
||||
--ctp-surface1: #192c3a;
|
||||
--ctp-surface2: #203444;
|
||||
|
||||
/* Overlays */
|
||||
--ctp-overlay0: #2a4a5a;
|
||||
--ctp-overlay1: #3a6070;
|
||||
--ctp-overlay2: #4a788a;
|
||||
|
||||
/* Text */
|
||||
--ctp-text: #cce8f0;
|
||||
--ctp-subtext1: #9accd8;
|
||||
--ctp-subtext0: #68a8b8;
|
||||
|
||||
/* Icy cyan accent */
|
||||
--ctp-teal: #7dd3e8;
|
||||
--ctp-sky: #7dd3e8;
|
||||
--ctp-sapphire: #60bcd5;
|
||||
--ctp-blue: #48a8c0;
|
||||
--ctp-lavender: #7dd3e8;
|
||||
--ctp-mauve: #60bcd5;
|
||||
--ctp-pink: #48a8c0;
|
||||
--ctp-flamingo: #7dd3e8;
|
||||
--ctp-rosewater: #60bcd5;
|
||||
|
||||
/* Semantic */
|
||||
--ctp-red: #e07878;
|
||||
--ctp-maroon: #d09858;
|
||||
--ctp-peach: #d09858;
|
||||
--ctp-yellow: #c8c060;
|
||||
--ctp-green: #60c898;
|
||||
|
||||
/* UI tokens */
|
||||
--bg-app: #0e1d28;
|
||||
--bg-sidebar: #09141e;
|
||||
--bg-card: #132430;
|
||||
--bg-hover: #192c3a;
|
||||
--bg-player: #09141e;
|
||||
--bg-glass: rgba(14, 29, 40, 0.95);
|
||||
|
||||
--accent: #7dd3e8;
|
||||
--accent-dim: rgba(125, 211, 232, 0.07);
|
||||
--accent-glow: rgba(125, 211, 232, 0.14);
|
||||
|
||||
--text-primary: #cce8f0;
|
||||
--text-secondary: #68a8b8;
|
||||
--text-muted: #3a6878;
|
||||
|
||||
--border: #192c3a;
|
||||
--border-subtle: #132430;
|
||||
--border-dropdown: #223844;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.90);
|
||||
|
||||
--positive: #60c898;
|
||||
--warning: #c8c060;
|
||||
--danger: #e07878;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getAlbum, getArtist } from '../api/subsonic';
|
||||
import { shuffleArray, songToTrack, usePlayerStore } from '../store/playerStore';
|
||||
|
||||
/**
|
||||
* All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”.
|
||||
*/
|
||||
export async function playArtistShuffled(artistId: string): Promise<void> {
|
||||
const { albums } = await getArtist(artistId);
|
||||
if (albums.length === 0) {
|
||||
throw new Error('play_artist_no_tracks');
|
||||
}
|
||||
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
const tracks = sorted.flatMap(r =>
|
||||
[...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack),
|
||||
);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
throw new Error('play_artist_no_tracks');
|
||||
}
|
||||
|
||||
const shuffled = shuffleArray(tracks);
|
||||
usePlayerStore.getState().playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getAlbum, getSong } from '../api/subsonic';
|
||||
import { playAlbum } from './playAlbum';
|
||||
import { playArtistShuffled } from './playArtistShuffled';
|
||||
import { songToTrack, usePlayerStore } from '../store/playerStore';
|
||||
|
||||
/**
|
||||
* `getSong` → `getAlbum` → `getArtist`: one opaque Subsonic id may refer to a track,
|
||||
* album, or artist depending on the server.
|
||||
*/
|
||||
export async function playByOpaqueId(id: string): Promise<void> {
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const song = await getSong(trimmed);
|
||||
if (song) {
|
||||
usePlayerStore.getState().playTrack(songToTrack(song));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { songs } = await getAlbum(trimmed);
|
||||
if (songs.length > 0) {
|
||||
await playAlbum(trimmed);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* not an album */
|
||||
}
|
||||
|
||||
try {
|
||||
await playArtistShuffled(trimmed);
|
||||
} catch {
|
||||
throw new Error('play_by_id_not_found');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,57 @@ import { buildStreamUrl } from '../api/subsonic';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
|
||||
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
||||
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
||||
|
||||
/**
|
||||
* Subsonic `buildStreamUrl()` rotates `t`/`s` on every call; Rust matches by `id` (see `playback_identity`).
|
||||
*/
|
||||
export function streamUrlTrackId(url: string): string | null {
|
||||
if (!url.includes('stream.view')) return null;
|
||||
try {
|
||||
const fromUrl = new URL(url).searchParams.get('id');
|
||||
if (fromUrl) return fromUrl;
|
||||
} catch {
|
||||
// Fallback for non-standard/relative URLs: parse query manually.
|
||||
}
|
||||
const q = url.split('?')[1];
|
||||
if (!q) return null;
|
||||
for (const part of q.split('&')) {
|
||||
const [k, v = ''] = part.split('=');
|
||||
if (k === 'id') {
|
||||
try {
|
||||
return decodeURIComponent(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param enginePreloadedTrackId — song id for which `audio_preload` finished into the engine RAM slot
|
||||
* (parsed from `audio:preload-ready` payload URL).
|
||||
*/
|
||||
export function getPlaybackSourceKind(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
enginePreloadedTrackId: string | null = null,
|
||||
): PlaybackSourceKind {
|
||||
if (useOfflineStore.getState().getLocalUrl(trackId, serverId)) return 'offline';
|
||||
if (useHotCacheStore.getState().getLocalUrl(trackId, serverId)) return 'hot';
|
||||
const resolved = resolvePlaybackUrl(trackId, serverId);
|
||||
if (
|
||||
!resolved.startsWith('psysonic-local://')
|
||||
&& enginePreloadedTrackId
|
||||
&& trackId === enginePreloadedTrackId
|
||||
) {
|
||||
return 'hot';
|
||||
}
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
/** Offline library → hot playback cache → HTTP stream. */
|
||||
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
|
||||
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import type { ServerProfile } from '../store/authStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
/**
|
||||
* Ping, update server identity / Instant Mix probe, set active server, clear local playback
|
||||
* and pull the new server's saved play queue.
|
||||
*/
|
||||
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
|
||||
try {
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (!ping.ok) return false;
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
const auth = useAuthStore.getState();
|
||||
auth.setSubsonicServerIdentity(server.id, identity);
|
||||
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
usePlayerStore.getState().clearQueue();
|
||||
await usePlayerStore.getState().initializeFromServerQueue();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+23
-7
@@ -20,19 +20,21 @@ function reflow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export type ToastVariant = 'error' | 'info';
|
||||
export type ToastVariant = 'error' | 'info' | 'warning' | 'success';
|
||||
|
||||
export function showToast(text: string, durationMs = 4000, variant: ToastVariant = 'info'): void {
|
||||
const isError = variant === 'error';
|
||||
const isWarning = variant === 'warning';
|
||||
const isSuccess = variant === 'success';
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'psysonic-toast';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.textContent = isError ? '✕' : 'ℹ';
|
||||
icon.textContent = isError ? '✕' : isWarning ? '!' : isSuccess ? '✓' : 'ℹ';
|
||||
icon.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-size: ${isSuccess ? '10px' : '11px'};
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -40,7 +42,7 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${isError ? 'var(--danger)' : 'var(--accent)'};
|
||||
background: ${isError ? 'var(--danger)' : isWarning ? 'var(--warning, #f59e0b)' : isSuccess ? 'var(--success, #10b981)' : 'var(--accent)'};
|
||||
color: var(--bg-app);
|
||||
line-height: 1;
|
||||
`;
|
||||
@@ -49,6 +51,20 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant
|
||||
msg.textContent = text;
|
||||
msg.style.cssText = `flex: 1; min-width: 0;`;
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (isError) return 'var(--danger)';
|
||||
if (isWarning) return 'var(--warning, #f59e0b)';
|
||||
if (isSuccess) return 'var(--success, #10b981)';
|
||||
return 'var(--accent)';
|
||||
};
|
||||
|
||||
const getBoxShadow = () => {
|
||||
const base = '0 4px 24px rgba(0,0,0,0.45)';
|
||||
if (isError) return `${base}, 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)`;
|
||||
if (isWarning) return `${base}, 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 20%, transparent)`;
|
||||
return base;
|
||||
};
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: ${TOAST_BOTTOM_ANCHOR}px;
|
||||
@@ -59,15 +75,15 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant
|
||||
gap: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid ${isError ? 'var(--danger)' : 'var(--border)'};
|
||||
border-left: 3px solid ${isError ? 'var(--danger)' : 'var(--accent)'};
|
||||
border: 1px solid ${getBorderColor()};
|
||||
border-left: 3px solid ${getBorderColor()};
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
z-index: 999999;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45)${isError ? ', 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)' : ''};
|
||||
box-shadow: ${getBoxShadow()};
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
transition: bottom 150ms ease;
|
||||
|
||||
@@ -164,6 +164,14 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
|
||||
[columns, storageKey],
|
||||
);
|
||||
|
||||
const resetColumns = useCallback(() => {
|
||||
const defaultWidths = Object.fromEntries(columns.map(c => [c.key, c.defaultWidth]));
|
||||
const defaultVisible = new Set(columns.map(c => c.key));
|
||||
setColWidths(defaultWidths);
|
||||
setColVisible(defaultVisible);
|
||||
localStorage.removeItem(storageKey);
|
||||
}, [columns, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -180,6 +188,7 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
|
||||
gridStyle,
|
||||
startResize,
|
||||
toggleColumn,
|
||||
resetColumns,
|
||||
pickerOpen,
|
||||
setPickerOpen,
|
||||
pickerRef,
|
||||
|
||||
Reference in New Issue
Block a user