Compare commits

...

143 Commits

Author SHA1 Message Date
Psychotoxical d18646de23 chore(release): bump version to 1.34.12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:20:40 +02:00
Psychotoxical ccdb899833 fix(theme): improve Latte and GTA theme readability
Latte: fix server dropdown dark background (--bg-card instead of hardcoded #1e1e2e), add back-button white text over album cover overlay, fix NowPlaying white-on-white text (artist/album, badges, bio, tracklist, star/heart icons use semantic vars).

GTA: bump --text-muted from #484848 to #707070 and --text-primary from #e8e8e8 to #d8d8d8 — sidebar section labels, Settings descriptions and album detail info were nearly invisible on the near-black background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:44:18 +02:00
Psychotoxical b197694804 chore(about): add PRs #196, #198, #199, #200, #201 to contributors list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:00:02 +02:00
Frank Stellmacher b45bb95522 Merge pull request #201 from cucadmuh/feat/playback-source-indicator
feat(queue): playback source badge + preload-aware source tracking
2026-04-17 15:56:01 +02:00
Psychotoxical cd1c785e43 fix(queue): use data-tooltip instead of title for source badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:55:48 +02:00
Maxim Isaev 1e54946939 fix(audio): label streaming decoder logs by source type
Differentiate streaming decoder init/decode messages between queue track streaming and radio so errors are not misattributed to radio playback.
2026-04-17 16:43:43 +03:00
Psychotoxical 34e374cf03 fix(macos): remove NSMicrophoneUsageDescription to suppress spurious mic prompt
cpal/CoreAudio triggers macOS TCC mic check during output device enumeration.
Without NSMicrophoneUsageDescription, macOS silently denies input access instead
of showing a dialog — output playback is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:38:40 +02:00
Frank Stellmacher 9387ceca83 Merge pull request #198 from kveld9/feat/DRP-enhancement
feat: discord rich presence enhancement
2026-04-17 15:34:18 +02:00
Maxim Isaev c165669db5 feat(queue): show playback source in tech strip with preload tracking
Add source badges for offline, cache, and stream playback in the queue tech line, including localized tooltips across shipped locales.

Track Rust preload-ready events by stream track id and latch the selected source per track, with dev logs to debug preload/source mismatches during next-track handoff.
2026-04-17 16:34:11 +03:00
Psychotoxical c50addaacf fix(discord): remove dead fields, timeChanged invoke loop, and unsupported {paused} placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:33:58 +02:00
Frank Stellmacher 7e64ef8f2a Merge pull request #199 from kveld9/fix/csv-import-clean
fix: csv import improvements
2026-04-17 15:28:19 +02:00
Frank Stellmacher 8c87ba95ff Merge pull request #196 from cucadmuh/feat/issue-195-queue-replaygain
feat(queue): ReplayGain in current-track tech strip
2026-04-17 15:28:16 +02:00
Frank Stellmacher 54962a15c1 Merge pull request #200 from cucadmuh/feat/streaming-playback-stability
fix(audio): streaming start stability, seek recovery, and transition safeguards
2026-04-17 15:24:09 +02:00
Maxim Isaev 689d2dc019 fix(audio): harden stream playback transitions and cache promotion
Add resilient stream-first track playback with reconnect/range recovery, seek fallback handling for non-seekable starts, and promotion of completed streamed bytes into hot cache.

Also add crossfade/gapless backup preload windows to keep automatic transitions smooth on imperfect networks.
2026-04-17 13:49:31 +03:00
kveld9 f3fb730fbc csv import improvements 2026-04-17 02:55:31 -03:00
kveld9 5576fb0d13 fix DRP timer 2026-04-16 21:41:22 -03:00
kveld9 99c6720b86 fix Settings.tsx and authStore.ts 2026-04-16 21:05:56 -03:00
kveld9 7b20965c1b fix locales 2026-04-16 20:56:20 -03:00
kveld9 28844e8456 release 2026-04-16 20:34:55 -03:00
Maxim Isaev 572eb81927 feat(queue): show ReplayGain metadata in current-track tech strip
Display track gain, album gain, and peak from file tags next to format,
bitrate, and sample rate so missing ReplayGain tags are obvious.

Refs: https://github.com/Psychotoxical/psysonic/issues/195
2026-04-16 23:15:13 +03:00
Psychotoxical b6812de26b remove(seekbar): drop realtime_waveform style
Downloaded and fully decoded the entire audio file on every track
change — too CPU/bandwidth intensive to keep. Removes SeekbarStyle
variant, Rust command, waveform_decode function, all frontend canvas
code, seekbarRealtime_waveform i18n keys across all 8 locales, and
unused urlencoding dependency from Cargo.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:15:48 +02:00
Psychotoxical 2f1d52e100 docs(readme): shorten features list and remove completed roadmap
Condensed 27 feature bullets to 14 key highlights, replaced exact
theme count with "wide selection", added CLI Control entry, and
removed the completed roadmap section (redundant with features list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:38:12 +02:00
Psychotoxical 06902acd4b feat(help): add missing sections and questions (Device Sync, Internet Radio, CLI, Playlists, Infinite Queue, Lyrics sources, Audio device, Backup & Restore, Now Playing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:10:40 +02:00
Psychotoxical 2deaed50b8 chore(about): add missing features from cucadmuh PR #187 to contributors list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:58:05 +02:00
Psychotoxical 3ac82595f9 docs(privacy): add NetEase Cloud Music lyrics source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:50:28 +02:00
Psychotoxical 7db42d74ef feat: tracklist column reset, device sync cross-platform template fix, filename template builder, privacy policy
- Add "Reset to defaults" button to column picker in AlbumTrackList, PlaylistDetail, Favorites (all 8 locales)
- Fix device sync cross-OS status detection: store filenameTemplate in manifest, use it for path computation on import
- Redesign filename template UI: preset buttons (Standard/Multi-Disc/Alt. Folder), clickable token chips ({artist}, {album}, etc.), clear button
- Fix suggestions section in PlaylistDetail missing album column rendering
- Add PRIVACY.md documenting all third-party integrations (Last.fm, LRCLIB, Apple Music, Discord); link from README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:47:41 +02:00
Psychotoxical 58851da817 Update README.md 2026-04-16 08:45:51 +02:00
Psychotoxical 3262458cd3 Update README.md 2026-04-16 08:45:18 +02:00
Psychotoxical bf3d896016 fix(device-sync): auto-import manifest on mount, clear view on disconnect
- Read psysonic-sync.json automatically when DeviceSync page opens and
  drive is already connected (no manual folder re-select needed)
- Always replace sources from manifest when choosing a folder, so
  switching between sticks loads the correct album list
- Hide source list and status badges when drive is disconnected
- Reset import flag on disconnect so re-plugging triggers a fresh import
- Fix unused `mut` warning in cancel_device_sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 23:16:06 +02:00
Psychotoxical 442ed9d191 feat(device-sync): manifest, cancel, font picker + sync status fix
- Write psysonic-sync.json to device after sync/deletions; auto-import
  on folder select when localStorage is empty (cross-platform handoff)
- Add cancel button during active sync: AtomicBool flag per job,
  tasks bail after acquiring semaphore, cancelled state in UI
- Fix sync status staying "pending": normalize template path separators
  to OS separator (Windows: '/' → '\') so compute_sync_paths and
  list_device_dir_files produce matching strings for Set comparison
- Add Geist and JetBrains Mono as variable fonts; font picker collapsible
- Fix device mount detection on Windows: removable drive letters (E:\)
  were incorrectly skipped alongside system roots; strip \?\ prefix
  from canonicalized paths before mount-point comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:59:27 +02:00
Psychotoxical e23280c013 feat(sidebar): add split Mix navigation mode with separate sidebar entries
Extract ALL_NAV_ITEMS to src/config/navItems.ts and add randomMix/randomAlbums
nav items. A new toggle in Settings switches between a single "Build a Mix" hub
and separate "Random Mix" / "Random Albums" sidebar entries (randomNavMode in
authStore). Fixes reorder crash caused by hidden items being overwritten with
undefined during the merge step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:27:53 +02:00
Psychotoxical 189bb519eb chore(about): add PR #184–#192 to contributors list
cucadmuh: CLI player controls + D-Bus forwarding + shell completions (PR #187)
kveld9: Favorites redesign, albums/playlists headers, column picker fixes,
        CSV import, search context menu (PR #184, #186, #188, #190, #191, #192)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:04:14 +02:00
Psychotoxical c573784fbb Merge pull request #192: fix tracklist column picker alignment and toggle functionality 2026-04-15 20:59:49 +02:00
kveld9 03b56afc92 bug fixes 2026-04-15 15:26:56 -03:00
Psychotoxical f437017359 fix(cli): gate OnceLock import behind linux cfg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:03:56 +02:00
Psychotoxical 34acc46c12 fix(cli): suppress dead-code warnings on Windows for Linux-only helpers
Wrap tauri_identifier, single_instance_bus_name and single_instance_object_path
with #[cfg(target_os = "linux")] since they are only reachable via D-Bus paths.
Move the argv collection into the linux-only block in lib.rs for the same reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:01:17 +02:00
Psychotoxical d248a78acc fix(tracklist): revert overflow-x:visible regression from PR #188
.content-body has overflow-x:hidden — setting overflow-x:visible on
.tracklist would clip wide tables instead of scrolling them. Keep auto.
Also remove Spanish code comments (contributors are international).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:35:28 +02:00
Psychotoxical a4d8dd41fd Merge pull request #188 from kveld9/fix/tracklist-picker-overflow 2026-04-15 18:34:36 +02:00
Psychotoxical b7a2e91a62 fix(search): remove duplicate const total in AdvancedSearch
PR #191 moved the declaration to the top of the component but left
the original at line 139, causing a 'has already been declared' error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:32:47 +02:00
Psychotoxical d519042917 Merge pull request #191 from kveld9/feat/search-context-menu
added context menu to search panel songs
2026-04-15 18:27:06 +02:00
Psychotoxical ccbf3605a0 chore: update package-lock after moving @types/papaparse to devDeps 2026-04-15 18:22:30 +02:00
Psychotoxical fce8dc3208 fix(csv-import): guard s.isrc type before toUpperCase, restore public toggle, move @types to devDeps
- s.isrc from Subsonic API may be non-string at runtime; add typeof guard
  to prevent TypeError crashing all 80 search calls into "network errors"
- Restore isPublic toggle in PlaylistEditModal footer (accidentally removed
  in PR #190); keep new Cancel button alongside it
- Move @types/papaparse from dependencies to devDependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:21:23 +02:00
Psychotoxical 2384b73908 fix(post-merge #190): restore public-toggle in PlaylistEditModal, move @types/papaparse to devDeps
- PlaylistEditModal footer lost the isPublic toggle in the CSV import PR;
  restore it alongside the new Cancel button
- @types/papaparse is a type-only package — belongs in devDependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:10:19 +02:00
Psychotoxical 7cd0eaf9e1 Merge pull request #190 from kveld9/feat/playlist-csv-import
feature: spotify CSV playlist import
2026-04-15 18:09:27 +02:00
Psychotoxical 9383a6f6ed feat(radio): forward ICY StreamTitle to MPRIS metadata
When a radio stream is active, push the station name to MPRIS on start
and forward ICY StreamTitle updates (radio:metadata events) so the OS
now-playing overlay stays in sync. Skips ad metadata (is_ad flag) and
suppresses position ticks for radio streams (no meaningful duration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:14 +02:00
Psychotoxical 624bd6bff7 fix(i18n): add common.play key to all 8 locales (fix for PR #186)
The AlbumHeader and PlaylistDetail play buttons use t('common.play') which
was missing — 'Reproducir' would have appeared as fallback in all languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:14 +02:00
Psychotoxical 67f02def43 Merge pull request #186 from kveld9/redesign/albums-playlists-headers
redesign/albums playlists headers
2026-04-15 18:02:16 +02:00
Psychotoxical 1a2bc3f65b Merge pull request #187 from cucadmuh/feat/cli-completions-player-controls
Reviewed and merged. Clean architecture, correct D-Bus forwarding design, solid completions scripts. Minor nits noted in review (parse_cli_command called 4x, search fetches all types, 500ms sleep comment) — none blocking.
2026-04-15 17:56:45 +02:00
kveld9 56d025f150 added context menu to search panel songs 2026-04-15 12:36:32 -03:00
kveld9 602811908b translations 2026-04-15 12:21:13 -03:00
kveld9 fc90617e9c some fixes 2026-04-15 11:50:44 -03:00
kveld9 a44581e5d6 new feature: import spotify playlist with .csv 2026-04-15 11:19:31 -03:00
kveld9 eb061bd9de fix album tracklist column picker cutoff and scroll containment issues 2026-04-14 23:27:57 -03:00
Maxim Isaev 6533991e7d feat(cli): expand remote player, opaque play id, tray without libindicator
Add CLI commands for stop, shuffle, repeat, mute, star, rating, reload,
server list/switch, and Subsonic search; publish extra snapshot fields and
server/search JSON for tooling. Single `play <id>` resolves track, album,
or artist; artists play a shuffled full discography.

Guard Linux tray creation with catch_unwind when Ayatana/AppIndicator .so
is missing. Harden bash completion against zsh sourcing (compopt). Narrow
AudioEngine field visibility to silence private_interfaces warnings.
2026-04-15 01:47:46 +03:00
Maxim Isaev c75297fcf6 feat(cli): completions, library/audio commands, server switcher
Embed bash and zsh completion scripts and add a `completions` subcommand.
Extend the player CLI (library, audio device, instant mix) and surface
`music_library` in snapshots. Add a shared active-server switcher in the
header and locales; instant mix can reseed the queue from CLI and UI.
2026-04-15 01:47:46 +03:00
Psychotoxical 19b7c8ec06 fix(favorites): correct post-merge issues from PR #184
- btn-sm: add global .btn-sm modifier to theme.css (was only scoped inside device-sync)
- year filter: exclude songs without year metadata when filter is active
- showing count: only render "X of Y" label when a filter is actually active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:38:41 +02:00
kveld9 c3ebc9b951 some fixes 2026-04-14 17:38:08 -03:00
Psychotoxical 117be0d85e Merge pull request #184 from kveld9/feat/ux-redesign-favorites
feat: ux redesign favorites -- sortable columns, gender filter, age range filter, new columns.
2026-04-14 22:34:18 +02:00
Psychotoxical 12818e02f8 chore(aur): bump pkgver to 1.34.11, add cmake makedepend
cmake is required by symphonia-adapter-libopus (bundles libopus via CMake).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:30:15 +02:00
kveld9 7ceaf4d5f2 merge branch 'main' into redesign 2026-04-14 17:29:57 -03:00
Psychotoxical 76da684c8d ci: add cmake to Linux build dependencies
Required by symphonia-adapter-libopus which bundles and compiles
libopus from source via CMake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:28:59 +02:00
Psychotoxical 795701a447 chore(release): bump version to 1.34.11
- Changelog for v1.34.11 (Opus, Device Sync, community themes, SSL fix)
- Contributors updated in Settings About (PR #181, #182, #183)
- Version bumped to 1.34.11 in package.json, tauri.conf.json, Cargo.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:28:15 +02:00
kveld9 5340d696cf new design 2026-04-14 17:26:02 -03:00
Psychotoxical 0a14123a0b docs(readme): add cmake as required build prerequisite
symphonia-adapter-libopus bundles libopus and compiles it via cmake.
Without cmake installed, cargo build fails with a C compiler error.
Added install instructions for Linux, macOS, and Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:04:25 +02:00
Psychotoxical 7cd03248c8 Merge pull request #183 from cucadmuh/feat/opus-support
feat(audio): Opus playback via symphonia-adapter-libopus
2026-04-14 21:58:22 +02:00
Maxim Isaev 4c6fc4d6d5 merge: integrate origin/main into feat/opus-support 2026-04-14 22:52:35 +03:00
kveld9 6996dacdf7 sortable columns, gender filter, age range filter, new columns. 2026-04-14 16:49:06 -03:00
Psychotoxical 798812d5ef customization: 8 new themes have been implemented
customization: 8 new themes have been implemented
2026-04-14 21:47:51 +02:00
Psychotoxical 9f4c7382aa feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate)
feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate)
2026-04-14 21:47:48 +02:00
Psychotoxical d34de09673 feat(device-sync): overhaul Device Sync page
- New two-panel layout with live album search (search3, 300ms debounce)
  and 10 random albums when search is empty — replaces full paginated load
- Pre-sync summary modal: shows files to add/delete, net change, available
  space; Proceed button disabled when space is insufficient
- calculate_sync_payload now filters already-synced tracks (path exists on
  device) so add_bytes/add_count reflect the true delta
- Space check accounts for pending deletions: addBytes > availableBytes + delBytes
- Separate deviceSyncJobStore for ephemeral job progress state
- Removable drive detection + auto-poll every 5 s via sysinfo
- Desired-state diff: de-selecting a synced source stages it for deletion
  instead of silently removing it
- Status badges (Synced / Pending / Deletion) with matching row highlights
- Live-Search badge () and Zufallsalben section label (⇌) in album browser
- Status summary row height aligned with search field (52 px)
- i18n: all new keys added to all 8 locales (en/de/fr/nl/nb/zh/ru/es)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:37:35 +02:00
Maxim Isaev 1ff4f1ef0f feat(audio): add Opus playback via symphonia-adapter-libopus
Wire a lazy CodecRegistry built with symphonia::default::register_enabled_codecs
plus symphonia_adapter_libopus::OpusDecoder for SizedDecoder and radio decoders.

Raise package rust-version to 1.89 to match the MSRV required by
symphonia-adapter-libopus.
2026-04-14 22:17:29 +03:00
kveld9 9a9be253e3 8 new themes have been implemented 2026-04-14 14:40:23 -03:00
kveld9 38e59d7a5e feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate) 2026-04-14 14:21:48 -03:00
Psychotoxical 915f0143f7 fix(ssl): strip trailing slash in getBaseUrl + trust OS certificate store for HTTPS streaming
Fixes two bugs reported in #178:

- getBaseUrl() now strips trailing slashes from server URLs, consistent
  with restBaseFromUrl(). A trailing slash caused double-slash stream URLs
  (//rest/stream.view) which Caddy rejects. Trailing slash also caused
  browsing to return 0 results. Fix manually ported from PR #179 by kveld9.

- reqwest now loads the OS native certificate store in addition to
  Mozilla's webpki roots (rustls-tls-native-roots feature). Fixes HTTPS
  streaming failures where the server certificate is signed by a local CA
  (e.g. Caddy internal CA) that is trusted in the system keychain but not
  in Mozilla's root store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:16:08 +02:00
Psychotoxical e0bb02b65e Update CHANGELOG.md 2026-04-14 11:48:51 +02:00
Psychotoxical 0d5011c5be Update CHANGELOG.md 2026-04-14 11:48:01 +02:00
Psychotoxical 34d899806a Update CHANGELOG.md 2026-04-14 11:47:01 +02:00
Psychotoxical 928cdba17a Update CHANGELOG.md 2026-04-14 11:42:55 +02:00
Psychotoxical 17a5c92174 feat(device-sync): USB/SD card sync page (WIP)
Adds a new Device Sync page for transferring music from Navidrome to
USB drives or SD cards. Sidebar entry is hidden by default.

- Four new Tauri commands: sync_track_to_device, compute_sync_paths,
  list_device_dir_files, delete_device_file
- Filename template engine with cross-platform path sanitization
- 4-concurrent-worker download, live progress via device:sync:progress event
- Persistent source list (albums/playlists/artists) with checkbox deletion
- Expandable artist tree in browser panel (per-album selection)
- i18n: DE + EN complete; other locales have stub keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:02:13 +02:00
Psychotoxical 58f99ae846 chore: bump version to 1.34.11-dev 2026-04-13 22:25:49 +02:00
Psychotoxical ef9a620ac6 chore(aur): bump pkgver to 1.34.10 2026-04-13 22:23:55 +02:00
Psychotoxical 01f71cf50c chore(release): bump version to 1.34.10
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:21:07 +02:00
Psychotoxical 39966d1c65 Create FUNDING.yml 2026-04-13 22:07:56 +02:00
Psychotoxical cbf72c7eba chore(about): add cucadmuh PR #176 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:54:02 +02:00
cucadmuh 4207455440 fix(audio): Linux output device selection and watcher (#176)
* fix(audio): stabilize Linux output device picker and watcher

Keep pinned ALSA/cpal device ids stable when enumeration omits the active
sink or returns an equivalent name. Stop Linux device-watcher from clearing
the pin based on missing list entries; macOS and Windows still treat repeated
absence as unplugged. Settings refresh flow calls canonicalize and refetches
the list; add i18n for the out-of-list device label.

* fix(settings): sort audio output devices by label

cpal enumeration order is arbitrary; order the dropdown by readable label
and place the current OS default device first among concrete outputs.
2026-04-13 21:48:21 +02:00
Psychotoxical 9983d13122 chore(about): add kilyabin PR #175 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:48:13 +02:00
kilyabin 1b55e88cd5 fix(fullscreen-player): stop mesh blob and portrait animations in no-compositing mode (#175)
In software compositing (WEBKIT_DISABLE_COMPOSITING_MODE=1), WebKit renders
entirely on the CPU. The mesh blob animations were not covered by the
no-compositing overrides: blob-a occupies 130×130% of the viewport, so each
frame the CPU had to composite a surface larger than the screen. On high-DPI
displays (e.g. 8K) this drops the fullscreen player to ~10 FPS.

- Stop mesh blob pan animations (static gradients are preserved)
- Stop portrait drift animation (filter was already removed)
- Remove box-shadow from the seekbar played bar (its width changes on every
  playback tick; in software mode each change triggers a box-shadow repaint)
2026-04-13 20:46:56 +02:00
Psychotoxical 2326f1f94b feat(linux): AppImage bundle + force X11/XWayland on all Linux packages
- CI: add squashfs-tools dep, APPIMAGE_EXTRACT_AND_RUN=1, appimage to
  --bundles, and *.AppImage to upload glob
- main.rs: set GDK_BACKEND=x11 and WEBKIT_DISABLE_COMPOSITING_MODE=1
  on Linux before Tauri init — WebKitGTK on Wayland is unstable;
  both vars are overridable by setting them before launch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:45:00 +02:00
Psychotoxical 27ea221130 chore(about): update contributors, remove feature tagline, limit changelog to 3
- Add missing contributions: cucadmuh (PR #144, #167, #173, #174),
  kveld9 (PR #168), nisarg-78 (PR #115)
- Remove aboutFeatures tag line from all 8 locales (too dynamic to maintain)
- Changelog section now shows only the 3 most recent versions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:23:22 +02:00
cucadmuh 9cd4743d1c feat(settings): audio output device picker (labels, OS default, live refresh) (#173)
* feat(settings): clearer audio device labels for duplicate ALSA names

Show HDMI outputs as "Card (HDMI n)" from hdmi:DEV indices; include PCM and
optional subdevice for hw/plughw/sysdefault; label other ALSA plugins with
iface and PCM. When labels still collide, append a structured hint
(iface · card · PCM) instead of only truncating the raw device string.

* feat(settings): improve audio output device picker

Parse ALSA-style ids into clearer labels (HDMI with DEV index, PCM/subdevice
for hw/plughw/sysdefault). Disambiguate colliding labels; share stderr
suppression for Linux device enumeration.

Add audio_default_output_device_name and tag the matching list entry as the
current system output (i18n). While the Audio tab is open, refresh list and
mark on audio:device-changed and audio:device-reset without toggling the
refresh spinner. Show an error toast if listing devices fails.
2026-04-13 20:13:22 +02:00
cucadmuh e75fda168e fix(folder-browser): arrow navigation only without modifiers (#174)
Skip column/list arrow handling when any modifier is down. Detect modifiers
via nativeEvent and getModifierState (WebKit/WebView), and match arrow keys
by both key and code. On row buttons, preventDefault for plain arrows only
to avoid native focus/scroll stealing navigation. Filter field ArrowDown uses
the same modifier check.
2026-04-13 20:13:10 +02:00
Psychotoxical ec021516c7 fix(audio): device-reset false positive + stderr noise + i18n tooltip
- Device watcher requires 3 consecutive misses (~9 s) before triggering
  audio:device-reset, preventing false positives when ALSA temporarily
  hides a busy device from output_devices() enumeration
- Add stderr suppression to open_stream_for_device_and_rate (last source
  of ALSA terminal noise on Linux)
- Fix tooltip showing raw key 'common.refresh' — replaced with
  settings.audioOutputDeviceRefresh (added to all 8 locales)
- Device switch restarts track from beginning (no seek-back)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:00:36 +02:00
Psychotoxical 82e1b458f3 fix(audio): clean up ALSA device names for display on Linux
Raw ALSA names like "sysdefault:CARD=U192k" are replaced with
readable labels ("U192k") in the device dropdown. The underlying
value stays unchanged so rodio can still open the correct device.

Covers: sysdefault, hw, plughw, iec958, front, surround prefixes.
Names without ALSA structure (pipewire, pulse, default) are kept as-is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:46:53 +02:00
Psychotoxical fc7a5815ef feat(audio): add refresh button for device list in Settings
The module-level cache prevented newly connected USB audio devices
from appearing in the dropdown. Replace with a manual refresh button
(RotateCcw icon, spins while loading) next to the dropdown.

Device enumeration now runs on every Audio tab open and on explicit
refresh. ALSA stderr noise is already suppressed by the dup2 guard,
so re-enumeration is silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:38:58 +02:00
Psychotoxical 1cf5faff21 fix(audio): suppress ALSA stderr noise during device enumeration
Device watcher (3 s loop) and audio_list_devices both called
cpal output_devices(), triggering ALSA to probe unavailable backends
(JACK, OSS, dmix) and spam stderr with error messages.

Fix: redirect fd 2 to /dev/null for the duration of each enumeration
on Unix via libc dup/dup2 + RAII guard. Also add a module-level
cache in Settings.tsx so audio_list_devices is only invoked once
per app session instead of on every Audio tab activation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:30:24 +02:00
Psychotoxical 16cb4f5a6f feat(audio): audio output device selection (closes #169)
Adds the ability to choose which audio output device Psysonic plays
through — useful for USB DACs, dedicated soundcards, and multi-device setups.

Rust (audio.rs):
- open_stream_for_device_and_rate(device_name, rate): opens a named
  device by cpal name, falls back to system default if not found
- AudioEngine.selected_device: persists the chosen device across
  stream reopens so hi-res rate switches stay on the right device
- audio_list_devices: Tauri command — returns all output device names
- audio_set_device: Tauri command — switches device immediately,
  drops old sinks, emits audio:device-changed
- start_device_watcher: now handles two cases:
    1. No pinned device + system default changed → reopen on new default
    2. Pinned device disappeared (DAC unplugged) → clear selected_device,
       fall back to system default, emit audio:device-reset

Frontend:
- authStore: audioOutputDevice (string | null), persisted
- playerStore: applies stored device on cold start
- App.tsx: listens to audio:device-reset, clears authStore device
  and restarts playback on the fallback device
- Settings → Audio tab: device dropdown at top (above Hi-Res and EQ),
  uses CustomSelect (portal-based, styled), all 8 locales
- CustomSelect: added disabled prop

Note: exclusive mode (WASAPI exclusive, CoreAudio exclusive) is out
of scope for now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:20:23 +02:00
Psychotoxical 9b22327bb0 fix(login): clarify that https:// URLs are accepted in server URL field (#171)
https:// was already supported by the code (startsWith('http') check) but
the placeholder text only showed bare host:port examples, giving no hint
that a full URL with protocol is valid.

- Updated serverUrlPlaceholder in login namespace (all 8 locales) to show
  https://music.example.com as the domain example
- Added settings.serverUrlPlaceholder i18n key (all 8 locales) and wired it
  into AddServerForm — previously the placeholder was a hardcoded English string

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:59:19 +02:00
Psychotoxical dba89cc4e4 fix(offline): replace blocking overlay with slim banner; add server settings link (#170)
When offline with no cached content, the full-screen OfflineOverlay blocked
all navigation including Settings, making it impossible to fix a broken
server config. Replace it with the same slim banner used in offline-cache mode.

- OfflineBanner now handles both cases: cache (existing) and no-cache (new)
- No-cache banner shows server name and a direct link to Settings → Server tab
- OfflineOverlay component is no longer used (import removed from App.tsx)
- All 8 locales: added offlineNoCacheBanner and serverSettings keys

Fixes #170

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:52:17 +02:00
Psychotoxical 91d4cb58b8 feat: playlist management enhancements & UX improvements (#168)
- Multi-selection for Albums, Artists, Playlists with context menu bulk-add
- Collapsible playlist section in sidebar
- Infinite scroll on Artists page (IntersectionObserver)
- Submenu flip-up on viewport overflow
- Remove from Playlist in context menu
- All 8 locales synced

Fixes applied:
- title= → data-tooltip on playlist toggle button (CLAUDE.md)
- Hardcoded Spanish aria-label → i18n (sidebar.expandPlaylists/collapsePlaylists)
- AddToPlaylistSubmenu fetches playlists on first open if store empty

Co-Authored-By: kveld9 <kveld9@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:37:50 +02:00
Psychotoxical 374ebce9b5 feat(keybindings): in-app modifier chords; fix seek shortcut units (#167)
feat(keybindings): in-app modifier chords; fix seek shortcut units
2026-04-13 17:35:45 +02:00
Psychotoxical 083c5f62cb fix(pr168): title→data-tooltip, i18n playlist toggle, cold-start playlist fetch
- Replace title= with data-tooltip on sidebar playlist expand button (CLAUDE.md)
- Replace hardcoded Spanish aria-label strings with i18n keys (sidebar.expandPlaylists / collapsePlaylists) across all 8 locales
- AddToPlaylistSubmenu now fetches playlists on first open if store is empty (regression fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:33:56 +02:00
kveld9 592a479c30 fixed some bugs 2026-04-12 20:43:31 -03:00
Maxim Isaev 6439200e95 feat(keybindings): in-app modifier chords; fix seek shortcut units
Persist chords as ctrl/alt/shift/super plus key code; legacy single-key
bindings still match without modifiers. Settings capture uses buildInAppBinding;
App uses matchInAppBinding and skips chords also registered as global shortcuts.
Share MODIFIER_KEY_CODES and formatBinding with global shortcut formatting.

Fix seek-forward/backward hotkeys: seek() expects 0-1 progress, not seconds.
2026-04-13 01:01:50 +03:00
kveld9 9cb1471170 chore: apply stashed changes 2026-04-12 17:55:13 -03:00
kveld9 21e26e2604 chore: remove tauri generated schemas from tracking 2026-04-12 17:54:16 -03:00
kveld9 94323e91fa fixed bugs 2026-04-12 17:53:52 -03:00
kveld9 bef6941a2b feat: add multi-selection and context menu for playlist management 2026-04-12 17:53:52 -03:00
Psychotoxical 805b6bf163 chore(gen): update Tauri schema files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:51 +02:00
Psychotoxical fee454da5d fix(folder-browser): correct --bg-base to --bg-app in filter bar; add cucadmuh PR #165
--bg-base is undefined — sticky filter bar would have rendered transparent.
Also added PR #165 to cucadmuh's contributions in Settings About.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:40 +02:00
Psychotoxical a765bcd5d0 feat(themes): add Vision Dark & Vision Navy colorblind-safe themes (closes #166)
Purple & Gold palette (Deuteranopia / Protanopia / Tritanopia safe).
Vision Dark: near-black #0D0B12 + Gold #FFD700 (~14.7:1 WCAG AAA).
Vision Navy: deep navy #0A1628 + Gold #FFD700 (~14.5:1 WCAG AAA).
New 'Accessibility' group in ThemePicker.
Removed stale theme count from aboutFeatures across all locales.

This is an initial step toward colorblind accessibility — color variables
alone cannot cover all CVD use cases. Structural improvements (secondary
indicators, pattern/shape cues) are still needed in future iterations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:29 +02:00
Psychotoxical 7e50c7af40 feat(folder-browser): per-column filter keyboard flow
feat(folder-browser): per-column filter keyboard flow
2026-04-12 18:41:08 +02:00
Maxim Isaev a12d6a4015 feat(folder-browser): add per-column filter and queue append hotkey flow
Add Ctrl+F filtering for the active Folder Browser column with keyboard handoff between filter and rows, and clear right-side filters when parent selection changes.

When pressing Shift+Enter on a filtered track list, append visible tracks to the queue instead of replacing the current queue.
2026-04-12 14:08:36 +03:00
Psychotoxical da1cc91ff1 fix(deps): bump @tauri-apps/plugin-fs to ^2.5.0 and plugin-dialog to ^2.7.0
Rust crates resolved to newer minor versions; npm pins were behind,
causing CI version-mismatch error on tauri build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:49:07 +02:00
Psychotoxical 08235e1aa5 chore(aur): bump PKGBUILD to 1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:46:45 +02:00
Psychotoxical 57dbd50092 docs: add CHANGELOG for v1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:44:08 +02:00
Psychotoxical 4a2baaf87d chore: bump version to 1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:38:48 +02:00
Psychotoxical 65e748c464 chore(settings): add cucadmuh PR #163 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:35:02 +02:00
Psychotoxical 346fe604d9 feat(contextMenu): add Trash2 icon to Remove + Artist öffnen unter Album öffnen
- "Diesen Song entfernen" bekommt Trash2-Icon
- Neuer Eintrag "Künstler öffnen" (goToArtist) nach "Album öffnen" in beiden Song-Kontextmenüs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:34:33 +02:00
Psychotoxical 600af0e527 feat: bulk multi-select + drag in PlaylistDetail, Favorites, and artist context menu (#157)
- PlaylistDetail: Ctrl/Cmd+Click enters select mode; bulk drag emits
  { type: 'songs', tracks } when ≥2 selected; filtered-view rows now
  also draggable as single songs
- Favorites songs: full multi-select system — Ctrl+Click, Shift+Click,
  header toggle-all checkbox, bulk-selected highlight, bulk drag to
  queue, bulk-bar with Add to Playlist + Clear
- ContextMenu: ArtistToPlaylistSubmenu resolves all artist album songs
  and forwards to AddToPlaylistSubmenu
- Locale: common.clearSelection + playlists.addSelected in all 7 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:34:33 +02:00
cucadmuh 2dd993257a feat(player): build infinite queue using instant-mix strategy (#163)
Prefer artist-driven candidates (Top + Similar) when extending infinite queue, with random songs as a fallback when mix sources are empty.
2026-04-12 12:34:27 +02:00
Psychotoxical c2f7d6d495 chore(about): add missing contributor entries for v1.34.4 PRs
kilyabin: PR #148 (ru locale), #155 (Build a Mix hub), #156 (FS player perf + settings)
cucadmuh: PR #158 (Folder Browser keyboard nav + context menus)
kveld9: new contributor — PR #159 (Spanish translation), #160 (column sorting)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:11:17 +02:00
Psychotoxical 6a8422ad1f fix(artists): add hover effect to alphabet filter buttons
Buttons were built with inline styles — no :hover possible. Replaced with
.artists-alpha-btn CSS class: accent-colored hover with subtle glow ring,
active state unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:52:31 +02:00
Psychotoxical 8f45f7230e fix(statistics): accurate counts for large libraries + album-artist tooltip
Remove the 10-page cap in fetchStatisticsLibraryAggregates — the loop now runs until
the server returns a partial page, so albums/songs/playtime reflect the full library
regardless of size (previously capped at 5,000 albums). Switched sort type from
'newest' to 'alphabeticalByName' for a stable pagination order.

Add a tooltip on the Artists stat card explaining it shows album artists only (Subsonic
API limitation — track-level featured/guest artists without their own album are not
included). Tooltip added in all 8 locales. Labels with a tooltip get a dotted underline
and cursor:help as visual hint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:47:41 +02:00
cucadmuh ae2e1bcb97 feat(folder-browser): keyboard nav, context menus, now-playing path UX
Adds full keyboard navigation (arrow keys, Enter, Ctrl+Enter for context menu) across Folder Browser columns. Context menus for all row types with keyboard-operable submenus and star-rating control via arrow keys + Enter. Now-playing path is visually emphasized and stays stable; rebuilds on hotkey re-invoke or active-path follow-along. Adaptive column layout for deep trees with right-side visibility priority. New configurable 'Open Folder Browser' keybinding. StarRating animation sync for keyboard-driven changes. All 7 locales updated.
2026-04-12 11:27:26 +02:00
kilyabin 3d07a877f2 feat(fullscreen): performance fixes + appearance settings
Adds no_compositing_mode Tauri command; frontend adds html.no-compositing on Linux to replace GPU-only CSS effects (backdrop-filter, filter, mask-image) with software-friendly equivalents.

Settings → Appearance → Fullscreen Player: toggle for artist portrait visibility + 0–80% dimming slider.

Fix: long words in lyric lines now wrap correctly.
2026-04-12 11:26:44 +02:00
kilyabin bf38a286cd feat(nav): merge Random Mix & Albums into Build a Mix hub
Replaces two sidebar entries (Random Mix, Random Albums) with a single 'Build a Mix' item (Wand2 icon) at /random. Landing page shows two cards — Mix by Tracks and Mix by Albums — with glow-on-hover. Old routes updated throughout. All 7 locales updated.
2026-04-12 11:26:27 +02:00
Kveld. 8f18f73b33 feat(i18n): add Spanish (es) translation
Adds complete Spanish locale with 964 translated strings. Registered in i18n.ts, added to language dropdown in Settings. 8 languages total.
2026-04-12 11:25:54 +02:00
Kveld. 1a2352009b feat(tracklist): column-header sorting for albums & playlists
Replaces dedicated sort buttons with clickable column headers in album and playlist detail views. Three-click cycle: asc → desc → natural order. Sortable by title, artist, album, favorite, rating, duration. Active column shown bold with ▲/▼ indicator.
2026-04-12 11:25:40 +02:00
Psychotoxical eedf6f9337 Update CHANGELOG.md 2026-04-12 11:08:12 +02:00
Psychotoxical b9b8f3fc15 feat(tracklist): multi-select + psyDnD, filter/sort, settings & UI polish
- AlbumTrackList: extract TrackRow as React.memo, selection state moved to
  selectionStore (Zustand) for O(1) re-renders per toggle; Ctrl/Cmd+Click
  enters select mode; drag selected tracks as {type:'songs'} payload;
  selection clears on outside click or song-list change
- QueuePanel: handle 'songs' multi-track drop type; whitelist drag types to
  suppress drop feedback for non-queue drags (lyrics grip etc.)
- AlbumDetail + PlaylistDetail: filter/sort toolbar (title/artist, natural
  order); disc grouping bypassed when sorted; playlist reorder DnD disabled
  while filter active
- useTracklistColumns: 'known' field auto-shows newly added columns for
  existing users
- PlayerBar: mute/unmute restores previous volume via premuteVolumeRef
  instead of hardcoded 0.7
- Settings/Input: reset buttons restyled as RotateCcw icon above card,
  matching HomeCustomizer layout
- i18n: filterSongs, sortNatural, sortByTitle, sortByArtist keys across
  all 7 locales
- components.css: album-card-title/artist nowrap to keep playlist grid
  cards uniform height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 11:42:47 +02:00
Psychotoxical b46acc88e3 fix(player): hot playback cache — minor follow-up (#153)
fix(player): hot playback cache — minor follow-up
2026-04-11 10:32:57 +02:00
Maxim Isaev b4c3124168 fix(player): minor hot-cache eviction, prefetch budget, and settings live size
Small fix for hot playback cache: eviction keeps current and next only;
prefetch up to five tracks when under cap, always fetch the immediate next;
grace for the previous current until debounce; run evict immediately on
MB or folder changes; re-read cap after download; optional Track.size;
live disk usage on Audio settings.
2026-04-11 03:29:59 +03:00
Psychotoxical 20dabbfd03 feat(lyrics): configurable sources with drag-to-reorder + feat(audio): ReplayGain pre-gain & fallback
Lyrics Sources:
- Replace lyricsServerFirst + enableNeteaselyrics toggles with a new
  Settings section — three sources (Server, LRCLIB, Netease) each
  individually toggled and drag-reorderable via psy-drop DnD
- useLyrics.ts iterates sources in user-defined order, skipping disabled
  ones; embedded SYLT from local files still wins unconditionally
- onRehydrateStorage migration from legacy fields on first load

ReplayGain Pre-Gain:
- New authStore fields: replayGainPreGainDb (0…+6 dB) and
  replayGainFallbackDb (-6…0 dB, untagged / radio)
- audio.rs compute_gain applies pre_gain_db and fallback_db
- Settings sliders under ReplayGain mode selector
- Radio HTML5 volume scaled by fallbackDb factor on playRadio

i18n(ru): incorporate PR #148 translation improvements (kilyabin)
All 7 locales updated for new keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:57:36 +02:00
Psychotoxical 003e7a3203 Merge pull request #148 from kilyabin/lang-ru-fix
i18n(ru): improvement of some translation strings
2026-04-10 23:56:10 +02:00
Psychotoxical c142e9e983 feat(radio): PLS/M3U playlist resolution + ICY metadata for playlist streams
Resolves PLS and M3U/M3U8 playlist URLs to their first direct stream URL
before playback and ICY metadata fetching. Stations configured with a
.pls or .m3u URL (e.g. SomaFM, schizoid.in) now play correctly and
report track metadata via ICY headers.

- Rust: parse_pls_stream_url / parse_m3u_stream_url helpers
- Rust: resolve_playlist_url (shared) + resolve_stream_url Tauri command
- fetch_icy_metadata: auto-resolves playlist URLs before connecting
- playerStore: playRadio() awaits resolve_stream_url before setting audio src

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:18:53 +02:00
Psychotoxical 549677ffd4 feat(random-mix): context-aware remix button + genre reset chip
Remix button now targets the active context: relabels to "{{genre}} neu
mixen" when a genre is selected and re-fetches that genre instead of the
global random pool. "Alle Songs" chip added as first genre option to
reset back to the full library mix without leaving the page.

Also fixes album-grid cards stretching to full page height on macOS
WebKit (align-items: start on .album-grid-wrap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:19:22 +02:00
Psychotoxical dcc3e52ad1 fix(store): reset conflicting hot cache + preload state on rehydration
Users who had both hotCacheEnabled and preloadMode !== 'off' before
mutual exclusion was enforced will have both reset to off on next start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:52 +02:00
Psychotoxical 4b6888049f fix(audio): properly suppress unused-variable warning for DecodeError msg
Use `let _ = msg` under `#[cfg(not(debug_assertions))]` so the binding
remains available in debug builds for the eprintln! format string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:34:55 +02:00
Psychotoxical f45892e975 chore(deps): fix npm audit vulnerabilities (axios, vite)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:29:02 +02:00
Psychotoxical 16c511c167 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:40 +02:00
kilyabin 9986149e2c i18n(ru): improvement of some translation strings 2026-04-10 19:25:52 +04:00
Psychotoxical 31f72b0459 Update CHANGELOG.md 2026-04-10 17:18:55 +02:00
Psychotoxical e060737de9 chore(aur): bump PKGBUILD to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:15:54 +02:00
95 changed files with 18086 additions and 14737 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: psychotoxic
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+4 -3
View File
@@ -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
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
@@ -148,7 +148,8 @@ jobs:
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
run: npm run tauri:build -- --bundles deb,rpm
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
@@ -156,5 +157,5 @@ jobs:
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
+1
View File
@@ -31,6 +31,7 @@ dist-ssr
# Tauri
src-tauri/target/
src-tauri/gen/
# Documentation
CLAUDE.md
+208 -2
View File
@@ -5,15 +5,221 @@ 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
- **AppImage bundle for Linux** + X11/XWayland enforcement on all Linux packages: CI now builds `.AppImage` in addition to `.deb` and `.rpm`. `GDK_BACKEND=x11` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` are set automatically at startup on all Linux packages — WebKitGTK on Wayland is unstable. Both environment variables are still overridable by setting them before launch.
- **Audio output device selection** *(Issue [#169](https://github.com/Psychotoxical/psysonic/issues/169))*: Settings → Audio now shows a dropdown of all available output devices. The current OS default is pinned at the top with a label; a Refresh button re-enumerates silently. A device watcher detects hot-plug events and emits `audio:device-reset` after ~9 s of consecutive misses, preventing false positives on busy ALSA devices. On Linux, technical ALSA prefixes are stripped for display (`sysdefault:CARD=U192k``U192k`).
- **Vision Dark & Vision Navy — colorblind-safe themes** *(Issue [#166](https://github.com/Psychotoxical/psysonic/issues/166))*: Two new themes using a Purple & Gold palette designed to be safe for Deuteranopia, Protanopia, and Tritanopia. Vision Dark pairs near-black `#0D0B12` with Gold `#FFD700` (~14.7:1 WCAG AAA); Vision Navy uses deep navy `#0A1628` + Gold (~14.5:1 WCAG AAA). Both appear under a new **Accessibility** group in the Theme Picker. These themes are a first step toward proper colorblind support and will be revised and expanded in upcoming releases — structural improvements such as secondary indicators and pattern/shape cues are still on the roadmap.
- **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 [@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 01 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.
### Fixed
- **Fullscreen Player — animation overhead in no-compositing mode** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#175](https://github.com/Psychotoxical/psysonic/pull/175))*: In software-rendering mode (`WEBKIT_DISABLE_COMPOSITING_MODE=1`) the mesh blob pan animations are now stopped (static gradients are preserved), the portrait drift animation is stopped, and `box-shadow` is removed from the seekbar played bar. The seekbar played bar width changes on every playback tick; triggering a full shadow repaint in software mode caused significant CPU overhead.
- **Folder Browser — arrow keys with modifier keys** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#174](https://github.com/Psychotoxical/psysonic/pull/174))*: Column and list arrow-key handling is now skipped when any modifier key is held, preventing conflicts with browser focus navigation and OS-level shortcuts. Modifier detection uses both `nativeEvent` and `getModifierState` for WebKit/WebView2 compatibility.
- **Audio output device — Linux stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#176](https://github.com/Psychotoxical/psysonic/pull/176))*: Pinned ALSA/cpal device IDs now stay stable when enumeration temporarily omits the active sink or returns an equivalent name. The Linux device-watcher no longer clears the pin based solely on missing list entries — only macOS and Windows treat repeated absence as "device unplugged". The Settings refresh flow calls `canonicalize` and refetches the list; an i18n label is now shown when the active device is no longer in the enumerated list.
- **Login — server URL field** *(Issue [#171](https://github.com/Psychotoxical/psysonic/issues/171))*: The placeholder text in the Add Server form was previously a hardcoded English string. It is now fully localised and clarifies that `https://` URLs are accepted.
- **Offline mode — non-blocking banner** *(Issue [#170](https://github.com/Psychotoxical/psysonic/issues/170))*: The full-screen blocking overlay shown when Psysonic starts without a cached library is replaced with a slim banner at the top of the content area. The banner includes a direct link to Server Settings so the user can fix the connection without navigating manually.
---
*Special thanks to everyone who contributed to this release:*
*[@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 playlist management overhaul.*
---
## [1.34.9] - 2026-04-12
### Added
- **Multi-select in Playlist Detail & Favorites** *(Issue [#157](https://github.com/Psychotoxical/psysonic/issues/157))*: The same Ctrl/Cmd+Click multi-select system that was previously exclusive to album track lists is now available everywhere. Hold Ctrl (or ⌘ on macOS) to enter select mode, Shift+Click to range-select, click the header checkbox to toggle all. Selected tracks can be dragged as a group directly into the queue. A bulk action bar appears with **Add to Playlist** and **Clear selection** options. Works in Playlist Detail (main tracklist) and in the Favorites song list.
- **"Open Artist" in context menu**: Song context menus now show an **Open Artist** entry directly below **Open Album**, navigating to the artist detail page. Previously only accessible via the tracklist artist link.
- **"Add to Playlist" for Artists**: The context menu for artists now includes an **Add to Playlist** submenu. Psysonic fetches all albums from the artist and collects every track, then forwards them to the playlist picker — identical to the existing album-level submenu.
- **Infinite queue — Instant Mix strategy** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#163](https://github.com/Psychotoxical/psysonic/pull/163))*: When Infinite Queue is enabled, Psysonic now builds the upcoming track list using the same artist-driven logic as Instant Mix. It fetches **Top Songs** and **Similar Songs** for the current track's artist, shuffles and deduplicates the pool, and only falls back to fully random songs when no artist-driven candidates are available. This results in much more coherent listening sessions that stay close to your current musical context.
- **Fullscreen Player — appearance settings** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: Settings → Appearance → Fullscreen Player now offers a toggle to show/hide the artist portrait and a 080 % dimming slider for the background portrait.
- **Build a Mix hub** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#155](https://github.com/Psychotoxical/psysonic/pull/155))*: The previous *Random Mix* and *Random Albums* sidebar entries have been merged into a single **Build a Mix** page (Wand icon) at `/random`. A landing card lets you choose between *Mix by Tracks* and *Mix by Albums*. Old routes remain fully functional.
- **Spanish translation** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#159](https://github.com/Psychotoxical/psysonic/pull/159))*: Complete Spanish (es) locale with 964 translated strings. Psysonic now ships in 8 languages: English, German, French, Dutch, Chinese, Norwegian, Russian, and Spanish.
- **Column-header sorting for Albums & Playlists** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))*: Track lists in Album Detail and Playlist Detail now support click-to-sort directly on the column headers. Three-click cycle: ascending → descending → natural order. Sortable columns: Title, Artist, Album, Favourite, Rating, Duration. The active column is shown bold with a ▲/▼ indicator.
- **Folder Browser — keyboard navigation & context menus** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))*: Full keyboard navigation in the Folder Browser with arrow keys, Enter to open, and Ctrl+Enter to open the context menu. Context menus for all row types include keyboard-operable submenus and star-rating control via arrow keys. The now-playing path is visually emphasized and updates live. Adaptive column layout prioritizes right-side visibility for deep directory trees. A new configurable *Open Folder Browser* keybinding is available in Settings → Keyboard.
- **PLS/M3U playlist resolution for Internet Radio**: Stations configured with a `.pls` or `.m3u`/`.m3u8` URL (e.g. SomaFM, schizoid.in) are now resolved to their first direct stream URL before playback. ICY metadata fetching also auto-resolves playlist URLs. Previously these stations would fail to play or show no track info.
- **Lyrics sources — configurable order & per-source toggle**: The old *Server First* toggle has been replaced with a full drag-to-reorder list in Settings → General. Three sources — **Server** (embedded/OpenSubsonic), **LRCLIB**, and **Netease Cloud Music** — can each be individually enabled or disabled, and their priority order is fully customisable. Embedded SYLT tags from local files always win unconditionally.
- **ReplayGain Pre-Gain & Fallback** *(audio)*: Two new sliders in Settings → Audio → ReplayGain:
- **Pre-Gain** (0+6 dB): added on top of every ReplayGain-tagged track for users who prefer a louder default.
- **Fallback Gain** (60 dB): applied to untagged tracks and internet radio streams, preventing volume jumps when switching between tagged and untagged content.
- **Context-aware Remix button in Build a Mix**: When a genre filter is active, the Remix button now re-fetches the same genre instead of resetting to the full library pool. An *All Songs* chip is available as the first genre option to return to the global mix without leaving the page.
- **AlbumTrackList multi-select & psyDnD** *(tracklist polish)*: Album track lists now support full multi-select with Ctrl/Cmd+Click, Shift+Click range selection, and drag-to-queue for multiple tracks simultaneously. The `TrackRow` component is `React.memo` with fine-grained Zustand selectors, so only the toggled row re-renders on selection change (O(1)).
- **Mute/unmute restores previous volume**: The mute button in the player bar now restores the volume to its level before muting instead of always jumping to 70 %.
### Fixed
- **Statistics — accurate counts for large libraries**: The statistics page was previously capped at 10 pages (≈ 5,000 albums), causing incorrect totals on larger libraries. The pagination loop now runs until the server returns a partial page, regardless of library size. Sort type changed to `alphabeticalByName` for stable pagination.
- **Statistics — Artists count tooltip**: The Artists card now shows a tooltip (dotted underline, cursor: help) explaining that the count reflects album artists only — a Subsonic API limitation. Featured or guest artists who do not have their own album are not counted. The tooltip is localised in all 8 languages.
- **Artists page — alphabet navigation hover effect**: The AZ filter buttons had inline styles that prevented `:hover` CSS from applying. Buttons are now styled via `.artists-alpha-btn` CSS class with an accent-coloured hover highlight and a subtle glow ring.
- **Hot Cache — eviction & prefetch budget**: Eviction now correctly keeps only the current and next track; prefetch fetches up to five tracks when under the size cap but always fetches the immediate next; the previous current track is given a grace period until the debounce fires; eviction runs immediately on MB limit or folder changes; the cap is re-read after each download completes. Live disk usage is now shown on the Audio settings page.
- **Hot Cache + Preload — mutual exclusion on rehydration**: Users who had both Hot Cache and Preload enabled before the mutual-exclusion rule was introduced will have both automatically reset to off on first launch, preventing a conflicting state.
- **Fullscreen Player — Linux compositing performance** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: A new `no_compositing_mode` Tauri command detects Linux software-rendering mode and adds an `html.no-compositing` class, which swaps GPU-only CSS effects (`backdrop-filter`, `filter`, `mask-image`) for software-friendly equivalents throughout the fullscreen player.
- **Fullscreen Player — long lyric lines wrapping**: Long words in lyric lines now wrap correctly instead of overflowing the container.
- **Russian locale** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))*: Numerous translation improvements across the application, replacing machine-translated or awkward phrasings with natural Russian.
- **npm audit vulnerabilities**: Updated `axios` and `vite` to address reported security advisories.
### Changed
- **"Remove from Queue" context menu item** now has a **Trash** icon, matching the destructive action style of other delete operations.
- **Playlist Detail — filter-mode drag**: Rows in a filtered/sorted playlist view can now be dragged to the queue as single songs (previously dragging was disabled entirely in filter mode).
- **Infinite queue deduplication**: Tracks already present in the queue are excluded from the candidate pool, preventing the same song from appearing twice in a row during Infinite Queue sessions.
### Contributors
Thank you to everyone who contributed to **v1.34.9**:
- [@cucadmuh](https://github.com/cucadmuh) — Infinite queue via Instant Mix strategy (PR [#163](https://github.com/Psychotoxical/psysonic/pull/163)), Folder Browser keyboard navigation & context menus (PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))
- [@kilyabin](https://github.com/kilyabin) — Fullscreen Player performance & appearance settings (PR [#156](https://github.com/Psychotoxical/psysonic/pull/156)), Build a Mix hub (PR [#155](https://github.com/Psychotoxical/psysonic/pull/155)), Russian locale improvements (PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))
- [@Kveld9](https://github.com/Kveld9) — Spanish translation (PR [#159](https://github.com/Psychotoxical/psysonic/pull/159)), Column-header sorting (PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))
A huge thank you to all three of you — your contributions have made this one of the most feature-packed patch releases yet. Psysonic keeps getting better because of people like you. 🙌
---
## [1.34.8] - 2026-04-10
### Added
- **Netease Cloud Music Lyrics** *(opt-in)*: Netease Cloud Music can now be enabled in Settings → General as a last-resort lyrics fallback. It only fires when neither the server nor LRCLIB return results — the existing lyrics chain is completely unaffected. Particularly useful for Asian and international music. Chinese metadata lines (作词/作曲/编曲 etc.) are automatically stripped from the LRC output.
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now automatically detects whether [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic falls back to its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now supports [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) if it is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic keeps its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
- **ICY metadata & AzuraCast radio** *(contributed by [@sorensiimSalling](https://github.com/sorensiimSalling), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
- **ICY metadata & AzuraCast radio** *(contributed by [@nisrael](https://github.com/nisrael), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
- **Automatic audio device switching**: Psysonic now detects newly connected or changed audio output devices and switches to them automatically — no app restart required.
+47
View File
@@ -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.
+23 -64
View File
@@ -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 15 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 (80150%) 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 (15 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 (15 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.
+36
View File
@@ -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 repos `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 macOSs `/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`**.
+144
View File
@@ -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 0100'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
;;
esac
+167
View File
@@ -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
+67 -80
View File
@@ -1,16 +1,18 @@
{
"name": "psysonic",
"version": "1.34.7",
"version": "1.34.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.34.7",
"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",
@@ -20,8 +22,8 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2",
@@ -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": [
@@ -1605,21 +1573,21 @@
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.0.tgz",
"integrity": "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
@@ -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",
@@ -1942,14 +1920,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -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",
@@ -2696,10 +2680,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/react": {
"version": "18.3.1",
@@ -2996,9 +2983,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.8",
"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",
@@ -24,8 +26,8 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2",
@@ -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",
+2 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.7
pkgver=1.34.11
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')
+641 -260
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "psysonic"
version = "1.34.8"
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,7 +45,15 @@ 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 = [
-6
View File
@@ -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. -->
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-start-dragging","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+742 -92
View File
File diff suppressed because it is too large Load Diff
+1268
View File
File diff suppressed because it is too large Load Diff
+48 -37
View File
@@ -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;
}
+998 -11
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -2,5 +2,35 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
// Users can still override by setting these vars before launch.
#[cfg(target_os = "linux")]
unsafe {
if std::env::var("GDK_BACKEND").is_err() {
std::env::set_var("GDK_BACKEND", "x11");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.8",
"version": "1.34.12",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+388 -25
View File
@@ -21,6 +21,7 @@ import ArtistDetail from './pages/ArtistDetail';
import NewReleases from './pages/NewReleases';
import Favorites from './pages/Favorites';
import RandomMix from './pages/RandomMix';
import RandomLanding from './pages/RandomLanding';
import Settings from './pages/Settings';
import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
@@ -35,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';
@@ -44,7 +46,6 @@ import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import OfflineBanner from './components/OfflineBanner';
import OfflineLibrary from './pages/OfflineLibrary';
import Genres from './pages/Genres';
@@ -57,19 +58,34 @@ 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';
import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore';
import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
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 />;
@@ -87,6 +103,13 @@ function AppShell() {
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
}, []);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('no_compositing_mode').then(noComp => {
if (noComp) document.documentElement.classList.add('no-compositing');
}).catch(() => {});
}, []);
useEffect(() => {
const win = getCurrentWindow();
// Check initial state (e.g. app launched maximised / already fullscreen).
@@ -333,27 +356,21 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
{connStatus === 'disconnected' && hasOfflineContent && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
)}
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
{connStatus === 'disconnected' && !hasOfflineContent && (
<OfflineOverlay
serverName={serverName}
onRetry={connRetry}
isChecking={connRetrying}
/>
)}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/random-albums" element={<RandomAlbums />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random-mix" element={<RandomMix />} />
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
@@ -369,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>
@@ -400,6 +418,7 @@ function AppShell() {
// Media key + tray event handler
function TauriEventBridge() {
const navigate = useNavigate();
const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
@@ -419,13 +438,10 @@ function TauriEventBridge() {
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
@@ -435,6 +451,278 @@ function TauriEventBridge() {
return () => { unlisten?.(); };
}, []);
// Pinned output device was unplugged — Rust already fell back to system default.
// Clear the stored device so the Settings dropdown resets to "System Default".
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-reset', () => {
useAuthStore.getState().setAudioOutputDevice(null);
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
playTrack(currentTrack);
} else {
resetAudioPause();
}
}).then(u => { unlisten = u; });
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);
@@ -447,15 +735,18 @@ function TauriEventBridge() {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Global shortcuts use modifier combos — skip in-app bindings for those
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
if (e.ctrlKey || e.altKey || e.metaKey) return;
const chord = buildInAppBinding(e);
if (chord) {
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
if (registered.includes(chord)) return;
}
const { bindings } = useKeybindingsStore.getState();
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
const action = (Object.entries(bindings) as [string, string | null][])
.find(([, code]) => code === e.code)?.[0];
.find(([, b]) => matchInAppBinding(e, b))?.[0];
if (!action) return;
e.preventDefault();
@@ -468,15 +759,22 @@ function TauriEventBridge() {
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
case 'seek-forward': {
const s = usePlayerStore.getState();
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.min(1, (s.currentTime + 10) / dur));
break;
}
case 'seek-backward': {
const s = usePlayerStore.getState();
seek(Math.max(0, s.currentTime - 10));
const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.max(0, (s.currentTime - 10) / dur));
break;
}
case 'toggle-queue': toggleQueue(); break;
case 'open-folder-browser':
navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } });
break;
case 'fullscreen-player': toggleFullscreen(); break;
case 'native-fullscreen': {
const win = getCurrentWindow();
@@ -496,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)); }],
];
@@ -531,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.
@@ -549,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;
}
+17 -13
View File
@@ -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;
@@ -631,11 +633,10 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const maxPages = 10;
let capped = false;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('newest', pageSize, 0);
for (let page = 0; page < maxPages; page++) {
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
@@ -653,12 +654,8 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
g.albumCount += 1;
}
if (albums.length < pageSize) break;
if (page === maxPages - 1) {
capped = true;
break;
}
offset += pageSize;
nextPage = getAlbumList('newest', pageSize, offset);
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
@@ -746,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[] }> {
+7 -3
View File
@@ -15,9 +15,10 @@ interface AlbumCardProps {
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
}
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -43,9 +44,12 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
aria-label={`${album.name} von ${album.artist}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
if (selectionMode) { e.preventDefault(); return; }
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
if (selectionMode && selectedAlbums.length > 0) {
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
} else {
openContextMenu(e.clientX, e.clientY, album, 'album');
}
}}
onMouseDown={e => {
if (selectionMode || e.button !== 0) return;
+33 -22
View File
@@ -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>
+421 -228
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import React, { useState, useEffect, useRef, useCallback } from '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';
@@ -9,6 +9,8 @@ import { useDragDrop } from '../contexts/DragDropContext';
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);
@@ -18,18 +20,14 @@ 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 ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const 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 },
@@ -43,176 +41,95 @@ const COLUMNS: readonly ColDef[] = [
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where header label is centred in the cell (matches row controls below)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
interface AlbumTrackListProps {
songs: SubsonicSong[];
sorted?: boolean;
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
ratings: Record<string, number>;
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
userRatingOverrides: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
}
export default function AlbumTrackList({
songs,
hasVariousArtists,
currentTrack,
// ── TrackRow (memoised) ───────────────────────────────────────────────────────
// Subscribes only to its own boolean in the selection store → O(1) re-render on toggle.
interface TrackRowProps {
song: SubsonicSong;
globalIdx: number;
visibleCols: readonly ColDef[];
gridStyle: React.CSSProperties;
currentTrackId: string | null;
isPlaying: boolean;
ratingValue: number;
isStarred: boolean;
inSelectMode: boolean;
isContextMenuSong: boolean;
onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: AlbumTrackListProps['onContextMenu'];
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
setContextMenuSongId: (id: string | null) => void;
}
const TrackRow = React.memo(function TrackRow({
song,
globalIdx,
visibleCols,
gridStyle,
currentTrackId,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
ratingValue,
isStarred,
inSelectMode,
isContextMenuSong,
onPlaySong,
onRate,
onToggleSongStar,
onContextMenu,
}: AlbumTrackListProps) {
onToggleSelect,
onDragStart,
setContextMenuSongId,
}: TrackRowProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
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;
// ── Bulk select ───────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, globalIdx);
const to = Math.max(lastSelectedIdx, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
return next;
});
setLastSelectedIdx(globalIdx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
if (!discs.has(disc)) discs.set(disc, []);
discs.get(disc)!.push(song);
});
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1;
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const renderCell = (colDef: ColDef) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} 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 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
const isResizable = !isLastCol;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (key) {
case 'num':
return (
<div
key="num"
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`}
className={`track-num${isActive ? ' track-num-active' : ''}${isActive && !isPlaying ? ' track-num-paused' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
{currentTrack?.id === song.id && isPlaying && (
{isActive && isPlaying && (
<span className="track-num-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
@@ -252,11 +169,11 @@ export default function AlbumTrackList({
return (
<div key="favorite" className="track-star-cell">
<button
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
);
@@ -264,7 +181,7 @@ export default function AlbumTrackList({
return (
<StarRating
key="rating"
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
value={ratingValue}
onChange={r => onRate(song.id, r)}
/>
);
@@ -277,8 +194,8 @@ export default function AlbumTrackList({
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>
);
@@ -293,7 +210,284 @@ export default function AlbumTrackList({
}
};
// ── Mobile tracklist ─────────────────────────────────────────────────────
return (
<div
className={`track-row track-row-va${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
onToggleSelect(song.id, globalIdx, false);
} else if (inSelectMode) {
onToggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
onDragStart(song, me);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => renderCell(colDef))}
</div>
);
});
// ── AlbumTrackList ────────────────────────────────────────────────────────────
export default function AlbumTrackList({
songs,
sorted,
hasVariousArtists: _hasVariousArtists,
currentTrack,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
onPlaySong,
onRate,
onToggleSongStar,
onContextMenu,
sortKey,
sortDir,
onSort,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// Selection state lives in selectionStore — only the toggled row re-renders (O(1)).
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const inSelectMode = selectedCount > 0;
const allSelected = selectedCount === songs.length && songs.length > 0;
const lastSelectedIdxRef = useRef<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state ──────────────────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
// Clear selection when the song list changes (different album / filter applied).
useEffect(() => {
useSelectionStore.getState().clearAll();
lastSelectedIdxRef.current = null;
}, [songs]);
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Clear selection on click outside the tracklist (header, album art, etc.)
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
useSelectionStore.getState().clearAll();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode, tracklistRef]);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
// ── Stable callbacks passed to memoised TrackRow ──────────────────────────
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
useSelectionStore.getState().setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
lastSelectedIdxRef.current = globalIdx;
return next;
});
}, [songs]);
// Drag: if the dragged song is part of the selection, drag all selected songs.
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
const { selectedIds } = useSelectionStore.getState();
if (selectedIds.has(song.id) && selectedIds.size > 1) {
const tracks = songs
.filter(s => selectedIds.has(s.id))
.map(s => songToTrack(s));
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
me.clientX, me.clientY,
);
} else {
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
me.clientX, me.clientY,
);
}
}, [songs, psyDrag]);
const toggleAll = useCallback(() => {
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
}
}, [allSelected, songs]);
// ── Disc grouping ─────────────────────────────────────────────────────────
const discs = new Map<number, SubsonicSong[]>();
if (!sorted) {
songs.forEach(song => {
const disc = song.discNumber ?? 1;
if (!discs.has(disc)) discs.set(disc, []);
discs.get(disc)!.push(song);
});
} else {
discs.set(1, songs as SubsonicSong[]);
}
const discNums = sorted ? [1] : Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = !sorted && discNums.length > 1;
const currentTrackId = currentTrack?.id ?? null;
// ── Sortable columns ──────────────────────────────────────────────────────
const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey);
const handleHeaderClick = (key: ColKey | string) => {
if (!isSortable(key) || !onSort) return;
onSort(key);
};
const renderSortIndicator = (key: SortKey) => {
if (sortKey !== key) return null;
return (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
const canSort = isSortable(key) && onSort;
const isActive = canSort && sortKey === key;
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div
key={key}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleHeaderClick(key)}
className={isActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator(key as SortKey)}
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
const isResizable = !isLastCol;
return (
<div
key={key}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleHeaderClick(key)}
className={isActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Mobile tracklist ──────────────────────────────────────────────────────
if (isMobile) {
return (
<div className="tracklist-mobile">
@@ -305,7 +499,7 @@ export default function AlbumTrackList({
</div>
)}
{discs.get(discNum)!.map(song => {
const isActive = currentTrack?.id === song.id;
const isActive = currentTrackId === song.id;
return (
<div
key={song.id}
@@ -338,48 +532,10 @@ export default function AlbumTrackList({
}
return (
<div className="tracklist" ref={tracklistRef}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
{/* Column visibility picker */}
<div className="tracklist-col-picker" ref={pickerRef}>
<>
{/* 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); }}
@@ -406,11 +562,63 @@ export default function AlbumTrackList({
</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}
onClick={e => {
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}
>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* ── Header ── */}
<div className="tracklist-header-wrapper">
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
</div>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
@@ -420,50 +628,35 @@ export default function AlbumTrackList({
CD {discNum}
</div>
)}
{discs.get(discNum)!.map((song) => {
{discs.get(discNum)!.map(song => {
const globalIdx = songs.indexOf(song);
return (
<div
<TrackRow
key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
toggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
song={song}
globalIdx={globalIdx}
visibleCols={visibleCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isStarred={starredSongs.has(song.id)}
inSelectMode={inSelectMode}
isContextMenuSong={contextMenuSongId === song.id}
onPlaySong={onPlaySong}
onRate={onRate}
onToggleSongStar={onToggleSongStar}
onContextMenu={onContextMenu}
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
/>
);
})}
</div>
))}
</div>
</>
);
}
+133 -15
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -15,9 +15,10 @@ interface Props {
onChange: (value: string) => void;
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
}
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
export default function CustomSelect({ value, options, onChange, className = '', style, disabled }: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -80,7 +81,8 @@ export default function CustomSelect({ value, options, onChange, className = '',
type="button"
className={`custom-select-trigger ${className}`}
style={style}
onClick={() => setOpen(v => !v)}
disabled={disabled}
onClick={() => { if (!disabled) setOpen(v => !v); }}
aria-haspopup="listbox"
aria-expanded={open}
>
+8 -3
View File
@@ -355,7 +355,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
}, [currentTrack?.artistId]);
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
// Pre-fetch next track's 300px cover into the IndexedDB cache.
// Selector returns only the coverArt id, so it only re-runs on actual changes.
@@ -419,7 +421,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
aria-label={t('player.fullscreen')}
data-idle={isIdle}
onMouseMove={handleMouseMove}
style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined}
style={{
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
'--fs-portrait-dim': String(fsPortraitDim / 100),
} as React.CSSProperties}
>
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
@@ -429,7 +434,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
</div>
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
<FsPortrait url={portraitUrl} />
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
+4 -2
View File
@@ -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}>
+19 -3
View File
@@ -1,18 +1,34 @@
import React from 'react';
import { WifiOff, RefreshCw } from 'lucide-react';
import { WifiOff, RefreshCw, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface Props {
onRetry: () => void;
isChecking: boolean;
showSettingsLink?: boolean;
serverName?: string;
}
export default function OfflineBanner({ onRetry, isChecking }: Props) {
export default function OfflineBanner({ onRetry, isChecking, showSettingsLink, serverName }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const message = showSettingsLink
? t('connection.offlineNoCacheBanner', { server: serverName })
: t('connection.offlineModeBanner');
return (
<div className="offline-banner">
<WifiOff size={14} />
<span>{t('connection.offlineModeBanner')}</span>
<span>{message}</span>
{showSettingsLink && (
<button
className="offline-banner-retry"
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
>
<Settings size={12} />
{t('connection.serverSettings')}
</button>
)}
<button
className="offline-banner-retry"
onClick={onRetry}
+10 -1
View File
@@ -46,6 +46,7 @@ export default function PlayerBar() {
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false);
const premuteVolumeRef = useRef(1);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
@@ -86,6 +87,7 @@ export default function PlayerBar() {
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
const radioMeta = useRadioMetadata(currentRadio ?? null);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -312,7 +314,14 @@ export default function PlayerBar() {
<div className="player-volume-section">
<button
className="player-btn player-btn-sm"
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
onClick={() => {
if (volume === 0) {
setVolume(premuteVolumeRef.current);
} else {
premuteVolumeRef.current = volume;
setVolume(0);
}
}}
aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
+74 -26
View File
@@ -1,7 +1,8 @@
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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
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';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -19,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 = [];
@@ -235,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);
@@ -272,6 +290,12 @@ export default function QueuePanel() {
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
/** Only these drag types may be dropped into the queue. */
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
const isQueueDrag = isPsyDragging && !!psyPayload && (() => {
try { return QUEUE_DROP_TYPES.has(JSON.parse(psyPayload.data).type); } catch { return false; }
})();
// Keep for the onPsyDrop radio-reject check below
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
})();
@@ -315,6 +339,8 @@ export default function QueuePanel() {
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
} else if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'songs') {
enqueueAt(parsedData.tracks as Track[], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map((s: any) => ({
@@ -374,9 +400,9 @@ export default function QueuePanel() {
return (
<aside
ref={asideRef}
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
className={`queue-panel${isQueueDrag ? ' queue-drop-active' : ''}`}
onMouseMove={e => {
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
if (!isQueueDrag || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
@@ -411,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 ? (
@@ -551,9 +600,9 @@ export default function QueuePanel() {
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {};
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isPsyDragging && externalDropTarget?.idx === idx) {
} else if (isQueueDrag && externalDropTarget?.idx === idx) {
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
@@ -647,10 +696,9 @@ export default function QueuePanel() {
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
try {
await createPlaylist(name, queue.map(t => t.id));
const playlists = await getPlaylists();
const created = playlists.find(p => p.name === name);
if (created) setActivePlaylist({ id: created.id, name: created.name });
const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queue.map(t => t.id));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false);
} catch (e) {
console.error('Failed to save playlist', e);
+113 -36
View File
@@ -1,38 +1,23 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
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, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X,
Settings,
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
// 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' },
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' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', 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 { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
export default function Sidebar({
@@ -48,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);
@@ -56,7 +47,16 @@ 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);
const playlistsLoading = usePlaylistStore(s => s.playlistsLoading);
const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists);
// Sort playlists alphabetically by name
const playlists = useMemo(() => {
return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name));
}, [playlistsRaw]);
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
@@ -114,12 +114,24 @@ export default function Sidebar({
setLibraryDropdownOpen(false);
};
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
// 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]);
@@ -217,17 +229,69 @@ export default function Sidebar({
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
item.to === '/playlists' ? (
// Playlists item with expand button
<div key={item.to} className="sidebar-playlists-wrapper">
<div className="sidebar-playlists-header-row">
<NavLink
to={item.to}
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
{!isCollapsed && (
<button
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
aria-expanded={playlistsExpanded}
aria-label={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
data-tooltip={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
>
<ChevronRight size={14} />
</button>
)}
</div>
{!isCollapsed && playlistsExpanded && (
<div className="sidebar-playlists-list">
{playlistsLoading ? (
<div className="sidebar-playlists-loading">
<div className="spinner" style={{ width: 14, height: 14 }} />
</div>
) : playlists.length === 0 ? (
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
) : (
playlists.map((pl: { id: string; name: string }) => (
<NavLink
key={pl.id}
to={`/playlists/${pl.id}`}
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? pl.name : undefined}
data-tooltip-pos="bottom"
>
<PlayCircle size={12} />
<span>{pl.name}</span>
</NavLink>
))
)}
</div>
)}
</div>
) : (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
)
))}
{/* Now Playing — fixed, always visible */}
@@ -301,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>
);
+35
View File
@@ -34,6 +34,8 @@ export default function StarRating({
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
const prevValueRef = React.useRef(value);
const internalClickRef = React.useRef(false);
const cappedValue = Math.min(Math.max(0, value), selectCap);
@@ -41,6 +43,38 @@ export default function StarRating({
if (value > 0) setSuppressHoverPreview(false);
}, [value]);
// Keep keyboard-driven changes visually in sync with mouse click effects.
React.useEffect(() => {
const prev = prevValueRef.current;
const next = value;
prevValueRef.current = value;
if (internalClickRef.current) {
internalClickRef.current = false;
return;
}
if (prev === next) return;
setPulseStar(null);
setClearShrinkStar(null);
if (next > prev) {
const star = Math.max(1, Math.min(selectCap, next));
requestAnimationFrame(() => {
requestAnimationFrame(() => setPulseStar(star));
});
return;
}
if (next < prev) {
const star = Math.max(1, Math.min(selectCap, prev));
if (next === 0) setSuppressHoverPreview(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setClearShrinkStar(star));
});
}
}, [value, selectCap]);
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
@@ -49,6 +83,7 @@ export default function StarRating({
setSuppressHoverPreview(false);
const next = cappedValue === n ? 0 : n;
internalClickRef.current = true;
onChange(next);
setHover(0);
+20
View File
@@ -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: [
@@ -123,6 +136,13 @@ export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
],
},
{
group: 'Accessibility',
themes: [
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700' },
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700' },
],
},
{
group: 'Social Media',
themes: [
+20 -3
View File
@@ -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);
+33
View File
@@ -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' },
};
+15 -14
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { invoke } from '@tauri-apps/api/core';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import { fetchNeteaselyrics } from '../api/netease';
@@ -47,8 +48,7 @@ export interface UseLyricsResult {
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
const enableNeteaselyrics = useAuthStore(s => s.enableNeteaselyrics);
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
const [loading, setLoading] = useState(!cached && !!currentTrack);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
@@ -159,29 +159,30 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
}
};
const fetchFns: Record<string, () => Promise<boolean>> = {
server: fetchServer,
lrclib: fetchLrclibFn,
netease: fetchNetease,
};
(async () => {
// Embedded lyrics from local file always win (most accurate SYLT data).
if (cancelled) return;
if (await fetchEmbedded()) return;
const [first, second] = lyricsServerFirst
? [fetchServer, fetchLrclibFn]
: [fetchLrclibFn, fetchServer];
if (cancelled) return;
if (await first()) return;
if (cancelled) return;
if (await second()) return;
// Netease as last fallback — only when explicitly enabled
if (enableNeteaselyrics) {
// Try enabled sources in user-defined order.
for (const src of lyricsSources) {
if (!src.enabled) continue;
const fn = fetchFns[src.id];
if (!fn) continue;
if (cancelled) return;
if (await fetchNetease()) return;
if (await fn()) return;
}
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
})();
return () => { cancelled = true; };
}, [currentTrack?.id, enableNeteaselyrics]); // eslint-disable-line react-hooks/exhaustive-deps
}, [currentTrack?.id, lyricsSources]); // eslint-disable-line react-hooks/exhaustive-deps
return { syncedLines, plainLyrics, source, loading, notFound };
}
+143 -18
View File
@@ -1,16 +1,67 @@
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from './api/subsonic';
import { useAuthStore } from './store/authStore';
import { useHotCacheStore } from './store/hotCacheStore';
import { HOT_CACHE_PROTECT_AFTER_CURRENT, useHotCacheStore, type HotCacheEntry } from './store/hotCacheStore';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore } from './store/playerStore';
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
import { usePlayerStore, type Track } from './store/playerStore';
import {
bumpHotCachePreviousTrackGrace,
clearHotCachePreviousGrace,
getDeferHotCachePrefetch,
} from './utils/hotCacheGate';
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
const PREFETCH_AHEAD = 5;
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
function sumCachedBytesInProtectedWindow(
queue: Track[],
queueIndex: number,
serverId: string,
entries: Record<string, HotCacheEntry>,
): number {
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
let sum = 0;
for (let i = protectLo; i <= protectHi; i++) {
const e = entries[entryKey(serverId, queue[i].id)];
if (e) sum += e.sizeBytes || 0;
}
return sum;
}
/** Conservative size guess so we do not prefetch when the protected window could exceed the cap. */
function estimateTrackHotCacheBytes(track: Track): number {
const sz = track.size;
if (typeof sz === 'number' && Number.isFinite(sz) && sz > 0) {
return Math.ceil(sz * 1.06);
}
const dur =
typeof track.duration === 'number' && Number.isFinite(track.duration) && track.duration > 0
? track.duration
: 240;
const sfx = (track.suffix || '').toLowerCase();
const lossless = /^(flac|wav|dsf|dff|alac|ape|wv)$/.test(sfx);
let kbps =
typeof track.bitRate === 'number' && Number.isFinite(track.bitRate) && track.bitRate > 0
? track.bitRate
: 320;
if (lossless && kbps < 800) {
kbps = Math.max(kbps, 900);
}
const raw = Math.ceil((dur * kbps * 1000) / 8);
return Math.max(256 * 1024, Math.ceil(raw * (lossless ? 1.2 : 1.15)));
}
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
let graceEvictTimer: ReturnType<typeof setTimeout> | null = null;
const pendingQueue: PrefetchJob[] = [];
let workerRunning = false;
@@ -20,6 +71,22 @@ function debounceMs(): number {
return Math.min(600, s) * 1000;
}
function scheduleEvictAfterPreviousGrace(): void {
if (graceEvictTimer) {
clearTimeout(graceEvictTimer);
graceEvictTimer = null;
}
const ms = debounceMs();
if (ms <= 0) {
void replanNow();
return;
}
graceEvictTimer = setTimeout(() => {
graceEvictTimer = null;
void replanNow();
}, ms);
}
function enqueueJobs(jobs: PrefetchJob[]) {
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
for (const j of jobs) {
@@ -56,7 +123,8 @@ async function runWorker() {
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
const { queue, queueIndex } = usePlayerStore.getState();
const player = usePlayerStore.getState();
const { queue, queueIndex } = player;
const wantIds = new Set(
queue
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
@@ -64,6 +132,14 @@ async function runWorker() {
);
if (!wantIds.has(job.trackId)) continue;
const track = queue.find(t => t.id === job.trackId);
if (!track) continue;
const hotEntries = useHotCacheStore.getState().entries;
const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries);
const est = estimateTrackHotCacheBytes(track);
const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId;
if (!isImmediateNext && occupied + est > maxBytes) continue;
const url = buildStreamUrl(job.trackId);
try {
const customDir = auth.hotCacheDownloadDir || null;
@@ -76,12 +152,14 @@ async function runWorker() {
});
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
const fresh = usePlayerStore.getState();
const authAfter = useAuthStore.getState();
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
await useHotCacheStore.getState().evictToFit(
fresh.queue,
fresh.queueIndex,
maxBytes,
auth.activeServerId,
customDir,
maxAfter,
authAfter.activeServerId ?? '',
authAfter.hotCacheDownloadDir || null,
);
} catch {
/* network / HTTP — skip */
@@ -93,10 +171,6 @@ async function runWorker() {
}
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
function scheduleReplan() {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
@@ -133,15 +207,21 @@ async function replanNow() {
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
const immediateNextId = queue[queueIndex + 1]?.id;
let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hot.entries);
const jobs: PrefetchJob[] = [];
for (const t of targets) {
if (offline.isDownloaded(t.id, serverId)) continue;
if (hot.entries[entryKey(serverId, t.id)]) continue;
jobs.push({
trackId: t.id,
serverId,
suffix: t.suffix || 'mp3',
});
const isImmediateNext = t.id === immediateNextId;
if (isImmediateNext) {
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
continue;
}
const est = estimateTrackHotCacheBytes(t);
if (projectedOccupied + est > maxBytes) break;
projectedOccupied += est;
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
}
enqueueJobs(jobs);
}
@@ -157,19 +237,61 @@ export function initHotCachePrefetch(): () => void {
const q = state.queue;
const i = state.queueIndex;
if (q === lastQueueRef && i === lastQueueIndex) return;
const prevIdx = lastQueueIndex;
const prevQ = lastQueueRef;
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
lastQueueRef = q;
lastQueueIndex = i;
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
const left = (prevQ as Track[])[prevIdx];
const a = useAuthStore.getState();
if (left && a.activeServerId) {
bumpHotCachePreviousTrackGrace(left.id, a.activeServerId, a.hotCacheDebounceSec);
scheduleEvictAfterPreviousGrace();
}
}
if (onlyIndexMoved) void replanNow();
else scheduleReplan();
});
let lastAuthSig = '';
const unsubAuth = useAuthStore.subscribe(state => {
const unsubAuth = useAuthStore.subscribe((state, prev) => {
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
if (sig === lastAuthSig) return;
lastAuthSig = sig;
if (state.hotCacheEnabled && state.isLoggedIn) scheduleReplan();
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (!state.hotCacheEnabled || !state.isLoggedIn) {
pendingQueue.length = 0;
clearHotCachePreviousGrace();
return;
}
const budgetSettingsChanged =
!prev ||
state.hotCacheMaxMb !== prev.hotCacheMaxMb ||
state.hotCacheDownloadDir !== prev.hotCacheDownloadDir ||
state.hotCacheEnabled !== prev.hotCacheEnabled ||
state.activeServerId !== prev.activeServerId ||
state.isLoggedIn !== prev.isLoggedIn;
const onlyDebounceChanged =
!!prev &&
state.hotCacheDebounceSec !== prev.hotCacheDebounceSec &&
!budgetSettingsChanged;
if (budgetSettingsChanged) {
if (prev && state.hotCacheMaxMb < prev.hotCacheMaxMb) {
pendingQueue.length = 0;
}
void replanNow();
} else if (onlyDebounceChanged) {
scheduleReplan();
}
});
void replanNow();
@@ -179,6 +301,9 @@ export function initHotCachePrefetch(): () => void {
unsubAuth();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null;
if (graceEvictTimer) clearTimeout(graceEvictTimer);
graceEvictTimer = null;
pendingQueue.length = 0;
clearHotCachePreviousGrace();
};
}
+5 -3
View File
@@ -3,10 +3,11 @@ import { initReactI18next } from 'react-i18next';
import { deTranslation } from './locales/de';
import { enTranslation } from './locales/en';
import { frTranslation } from './locales/fr';
import { nbTranslation } from './locales/nb';
import { nlTranslation } from './locales/nl';
import { ruTranslation } from './locales/ru';
import { zhTranslation } from './locales/zh';
import { nbTranslation } from './locales/nb';
import { ruTranslation } from './locales/ru';
import { nlTranslation } from './locales/nl';
import { esTranslation } from './locales/es';
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
@@ -16,6 +17,7 @@ i18n
resources: {
en: { translation: enTranslation },
de: { translation: deTranslation },
es: { translation: esTranslation },
fr: { translation: frTranslation },
nl: { translation: nlTranslation },
zh: { translation: zhTranslation },
+232 -2
View File
@@ -5,6 +5,7 @@ export const deTranslation = {
newReleases: 'Neueste',
allAlbums: 'Alle Alben',
randomAlbums: 'Zufallsalben',
randomPicker: 'Mix erstellen',
artists: 'Künstler',
randomMix: 'Zufallsmix',
favorites: 'Favoriten',
@@ -16,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',
@@ -23,8 +25,11 @@ export const deTranslation = {
mostPlayed: 'Meistgehört',
radio: 'Internetradio',
folderBrowser: 'Ordner-Browser',
deviceSync: 'Gerätesync',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen',
},
home: {
hero: 'Featured',
@@ -96,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',
@@ -105,10 +111,14 @@ export const deTranslation = {
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
unfavoriteAlbum: 'Album aus Favoriten entfernen',
removeFromQueue: 'Diesen Song entfernen',
removeFromPlaylist: 'Aus Playlist entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
addToPlaylist: 'Zur Playlist hinzufügen',
selectedPlaylists: '{{count}} Playlists ausgewählt',
selectedAlbums: '{{count}} Alben ausgewählt',
selectedArtists: '{{count}} Künstler ausgewählt',
songInfo: 'Song-Infos',
},
albumDetail: {
@@ -135,6 +145,7 @@ export const deTranslation = {
goToArtist: 'Zu {{artist}} wechseln',
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
trackTitle: 'Titel',
trackAlbum: 'Album',
trackArtist: 'Interpret',
trackGenre: 'Genre',
trackFormat: 'Format',
@@ -143,11 +154,17 @@ export const deTranslation = {
trackDuration: 'Dauer',
trackTotal: 'Gesamt',
columns: 'Spalten',
resetColumns: 'Zurücksetzen',
notFound: 'Album nicht gefunden.',
bioModal: 'Künstler-Biografie',
bioClose: 'Schließen',
ratingLabel: 'Bewertung',
enlargeCover: 'Vergrößern',
filterSongs: 'Titel filtern…',
sortNatural: 'Reihenfolge',
sortByTitle: 'AZ (Titel)',
sortByArtist: 'AZ (Künstler)',
sortByAlbum: 'AZ (Album)',
},
entityRating: {
albumShort: 'Albumbewertung',
@@ -196,6 +213,18 @@ 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',
mixByTracks: 'Mix nach Titeln',
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
mixByAlbums: 'Mix nach Alben',
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
},
randomAlbums: {
title: 'Zufallsalben',
@@ -217,6 +246,8 @@ export const deTranslation = {
title: 'Zufallsmix',
remix: 'Neu mixen',
remixTooltip: 'Neue Songs laden',
remixGenre: '{{genre}} neu mixen',
remixTooltipGenre: 'Neue {{genre}}-Songs laden',
playAll: 'Alle abspielen',
trackTitle: 'Titel',
trackArtist: 'Künstler',
@@ -238,6 +269,7 @@ export const deTranslation = {
blacklistToggle: 'Keyword-Filter',
genreMixTitle: 'Genre-Mix',
genreMixDesc: 'Top 20 Genres nach Songanzahl — klicken für einen Zufallsmix',
genreMixAll: 'Alle Songs',
genreMixLoadMore: '10 weitere laden',
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
shuffleGenres: 'Andere Genres anzeigen',
@@ -279,13 +311,18 @@ export const deTranslation = {
notFound: 'Keine Künstler gefunden.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
selectionCount: '{{count}} ausgewählt',
select: 'Mehrfachauswahl',
startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen',
addToPlaylist: 'Zur Playlist hinzufügen',
},
login: {
subtitle: 'Dein Navidrome Desktop Player',
serverName: 'Server-Name (optional)',
serverNamePlaceholder: 'Mein Navidrome',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com',
username: 'Benutzername',
usernamePlaceholder: 'admin',
password: 'Passwort',
@@ -309,6 +346,7 @@ export const deTranslation = {
offlineTitle: 'Keine Serververbindung',
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
offlineLibraryTitle: 'Offline-Bibliothek',
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
offlineAlbumCount: '{{n}} Album',
@@ -318,6 +356,11 @@ export const deTranslation = {
offlineFilterPlaylists: 'Playlists',
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',
},
@@ -345,7 +388,9 @@ 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',
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
bulkClear: 'Auswahl aufheben',
@@ -366,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',
@@ -383,6 +432,7 @@ export const deTranslation = {
servers: 'Server',
serverName: 'Server-Name',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com',
serverUsername: 'Benutzername',
serverPassword: 'Passwort',
addServer: 'Server hinzufügen',
@@ -467,6 +517,13 @@ export const deTranslation = {
hotCacheDebounceImmediate: 'Sofort',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Hot-Cache leeren',
audioOutputDevice: 'Audio-Ausgabegerät',
audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.',
audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste',
hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
@@ -482,12 +539,22 @@ 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',
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
enableNeteaselyrics: 'Netease Cloud Music Lyrics',
enableNeteaselyricsDesc: 'Netease Cloud Music als letzte Lyrics-Quelle verwenden, wenn Server und LRCLIB nichts liefern. Besonders gut für asiatische und internationale Musik.',
lyricsSourcesTitle: 'Lyrics-Quellen',
lyricsSourcesDesc: 'Wähle, welche Quellen für Lyrics abgefragt werden und in welcher Reihenfolge. Ziehen zum Sortieren. Deaktivierte Quellen werden übersprungen.',
lyricsSourceServer: 'Server',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
downloadsTitle: 'ZIP-Export & Archivierung',
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
downloadsDefault: 'Standard-Downloads-Ordner',
@@ -497,7 +564,6 @@ export const deTranslation = {
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein moderner Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Basiert auf Tauri v2 mit einer nativen Rust-Audio-Engine — schlank und schnell, aber vollgepackt mit Features: Wellenform-Seekbar, synchronisierte Lyrics, Last.fm-Integration, 10-Band-EQ, Crossfade, nahtlose Wiedergabe, Replay Gain, Genre-Browser und eine große Theme-Bibliothek.',
aboutFeatures: 'Multi-Server · Last.fm Scrobbling & Love · Vollbild Ambient Stage · Wellenform-Seek · Sync-Lyrics · 10-Band-EQ · Crossfade & Gapless · Replay Gain · Genres · 63 Themes · 5 Sprachen',
aboutLicense: 'Lizenz',
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
@@ -524,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',
@@ -540,6 +608,7 @@ export const deTranslation = {
shortcutSeekForward: '10s vorspulen',
shortcutSeekBackward: '10s zurückspulen',
shortcutToggleQueue: 'Warteschlange ein-/ausblenden',
shortcutOpenFolderBrowser: '{{folderBrowser}} öffnen',
shortcutFullscreenPlayer: 'Vollbild-Player',
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
tabSystem: 'System',
@@ -571,6 +640,8 @@ export const deTranslation = {
replayGainMode: 'Modus',
replayGainTrack: 'Track',
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (getaggte Dateien)',
replayGainFallback: 'Fallback (ohne Tags / Radio)',
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}} s',
@@ -590,6 +661,10 @@ export const deTranslation = {
infiniteQueue: 'Endlose Warteschlange',
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
experimental: 'Experimentell',
fsPlayerSection: 'Vollbild-Player',
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
fsPortraitDim: 'Abdunkelung des Fotos',
seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
seekbarWaveform: 'Wellenform',
@@ -610,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',
},
@@ -716,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 15. 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',
@@ -747,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',
@@ -756,6 +870,7 @@ export const deTranslation = {
genreDistribution: 'Genre-Verteilung (Top 20)',
loadMore: 'Mehr laden',
statArtists: 'Künstler',
statArtistsTooltip: 'Nur Album-Künstler — Künstler, die ausschließlich als Track-Künstler vorkommen (Featured, Gast usw.) und kein eigenes Album haben, werden nicht gezählt.',
statAlbums: 'Alben',
statSongs: 'Songs',
statGenres: 'Genres',
@@ -871,6 +986,7 @@ export const deTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Neue Vorschläge',
addSong: 'Zur Playlist hinzufügen',
addSelected: 'Ausgewählte hinzufügen',
cacheOffline: 'Playlist offline speichern',
offlineCached: 'Playlist gecacht',
removeOffline: 'Aus Offline-Cache entfernen',
@@ -889,6 +1005,51 @@ export const deTranslation = {
coverUpdated: 'Cover aktualisiert',
metaSaved: 'Playlist aktualisiert',
downloadZip: 'Herunterladen (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
addPartial: '{{added}} hinzugefügt, {{skipped}} übersprungen (Duplikate)',
addAllSkipped: 'Alle übersprungen ({{count}} Duplikate)',
addError: 'Fehler beim Hinzufügen von Songs',
createAndAddSuccess: 'Playlist "{{playlist}}" mit {{count}} Songs erstellt',
createError: 'Fehler beim Erstellen der Playlist',
deleteSuccess: '{{count}} Playlists gelöscht',
deleteFailed: 'Fehler beim Löschen von {{name}}',
deleteSelected: 'Ausgewählte löschen',
mergeSuccess: '{{count}} Songs in {{playlist}} zusammengeführt',
mergeNoNewSongs: 'Keine neuen Songs zum Hinzufügen',
mergeError: 'Fehler beim Zusammenführen von Playlists',
mergeInto: 'Zusammenführen in',
selectionCount: '{{count}} ausgewählt',
select: 'Auswählen',
startSelect: 'Auswahl aktivieren',
cancelSelect: 'Abbrechen',
loadingAlbums: '{{count}} Alben werden aufgelöst…',
loadingArtists: '{{count}} Künstler werden aufgelöst…',
myPlaylists: 'Meine Playlists',
noOtherPlaylists: 'Keine anderen Playlists verfügbar',
addToPlaylistSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
addToPlaylistNoNew: 'Keine neuen Songs für {{playlist}}',
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',
@@ -942,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',
},
};
+235 -3
View File
@@ -5,6 +5,7 @@ export const enTranslation = {
newReleases: 'New Releases',
allAlbums: 'All Albums',
randomAlbums: 'Random Albums',
randomPicker: 'Build a Mix',
artists: 'Artists',
randomMix: 'Random Mix',
favorites: 'Favorites',
@@ -17,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',
@@ -24,8 +26,11 @@ export const enTranslation = {
mostPlayed: 'Most Played',
radio: 'Internet Radio',
folderBrowser: 'Folder Browser',
deviceSync: 'Device Sync',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists',
},
home: {
hero: 'Featured',
@@ -97,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',
@@ -106,10 +112,14 @@ export const enTranslation = {
unfavoriteArtist: 'Remove Artist from Favorites',
unfavoriteAlbum: 'Remove Album from Favorites',
removeFromQueue: 'Remove from Queue',
removeFromPlaylist: 'Remove from Playlist',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
download: 'Download (ZIP)',
addToPlaylist: 'Add to Playlist',
selectedPlaylists: '{{count}} playlists selected',
selectedAlbums: '{{count}} albums selected',
selectedArtists: '{{count}} artists selected',
songInfo: 'Song Info',
},
albumDetail: {
@@ -136,6 +146,7 @@ export const enTranslation = {
goToArtist: 'Go to {{artist}}',
moreLabelAlbums: 'More albums on {{label}}',
trackTitle: 'Title',
trackAlbum: 'Album',
trackArtist: 'Artist',
trackGenre: 'Genre',
trackFormat: 'Format',
@@ -144,11 +155,17 @@ export const enTranslation = {
trackDuration: 'Duration',
trackTotal: 'Total',
columns: 'Columns',
resetColumns: 'Reset to defaults',
notFound: 'Album not found.',
bioModal: 'Artist Biography',
bioClose: 'Close',
ratingLabel: 'Rating',
enlargeCover: 'Enlarge',
filterSongs: 'Filter songs…',
sortNatural: 'Natural',
sortByTitle: 'AZ (Title)',
sortByArtist: 'AZ (Artist)',
sortByAlbum: 'AZ (Album)',
},
entityRating: {
albumShort: 'Album rating',
@@ -197,6 +214,18 @@ 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',
mixByTracks: 'Mix by Tracks',
mixByTracksDesc: 'Random selection of tracks from your entire library',
mixByAlbums: 'Mix by Albums',
mixByAlbumsDesc: 'Random album picks for your next discovery',
},
randomAlbums: {
title: 'Random Albums',
@@ -218,6 +247,8 @@ export const enTranslation = {
title: 'Random Mix',
remix: 'Remix',
remixTooltip: 'Load new random songs',
remixGenre: 'Remix {{genre}}',
remixTooltipGenre: 'Load new {{genre}} songs',
playAll: 'Play All',
trackTitle: 'Title',
trackArtist: 'Artist',
@@ -239,6 +270,7 @@ export const enTranslation = {
blacklistToggle: 'Keyword Filter',
genreMixTitle: 'Genre Mix',
genreMixDesc: 'Top 20 genres by song count — click to load a random mix',
genreMixAll: 'All Songs',
genreMixLoadMore: 'Load 10 more',
genreMixNoGenres: 'No genres found on server.',
shuffleGenres: 'Show different genres',
@@ -280,13 +312,18 @@ export const enTranslation = {
notFound: 'No artists found.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
selectionCount: '{{count}} selected',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
addToPlaylist: 'Add to Playlist',
},
login: {
subtitle: 'Your Navidrome Desktop Player',
serverName: 'Server Name (optional)',
serverNamePlaceholder: 'My Navidrome',
serverUrl: 'Server URL',
serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com',
username: 'Username',
usernamePlaceholder: 'admin',
password: 'Password',
@@ -310,6 +347,7 @@ export const enTranslation = {
offlineTitle: 'No server connection',
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
offlineModeBanner: 'Offline Mode — playing from local cache',
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
offlineLibraryTitle: 'Offline Library',
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
offlineAlbumCount: '{{n}} album',
@@ -319,6 +357,11 @@ export const enTranslation = {
offlineFilterPlaylists: 'Playlists',
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',
},
@@ -346,7 +389,9 @@ export const enTranslation = {
filterSearchGenres: 'Search genres…',
filterNoGenres: 'No genres match',
filterClear: 'Clear',
play: 'Play',
bulkSelected: '{{count}} selected',
clearSelection: 'Clear selection',
bulkAddToPlaylist: 'Add to Playlist',
bulkRemoveFromPlaylist: 'Remove from Playlist',
bulkClear: 'Clear selection',
@@ -367,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',
@@ -378,12 +427,14 @@ export const enTranslation = {
languageZh: 'Chinese',
languageNb: 'Norwegian',
languageRu: 'Russian',
languageEs: 'Spanish',
font: 'Font',
theme: 'Theme',
appearance: 'Appearance',
servers: 'Servers',
serverName: 'Server Name',
serverUrl: 'Server URL',
serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com',
serverUsername: 'Username',
serverPassword: 'Password',
addServer: 'Add Server',
@@ -468,6 +519,13 @@ export const enTranslation = {
hotCacheDebounceImmediate: 'Immediate',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Clear hot cache',
audioOutputDevice: 'Audio Output Device',
audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.',
audioOutputDeviceDefault: 'System Default',
audioOutputDeviceRefresh: 'Refresh device list',
audioOutputDeviceOsDefaultNow: 'current system output',
audioOutputDeviceListError: 'Could not load the audio device list.',
audioOutputDeviceNotInCurrentList: 'not in current list',
hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable native hi-res playback',
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
@@ -483,12 +541,22 @@ 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',
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
enableNeteaselyrics: 'Netease Cloud Music lyrics',
enableNeteaselyricsDesc: 'Use Netease Cloud Music as a last-resort lyrics source when server and LRCLIB both return nothing. Best coverage for Asian and international music.',
lyricsSourcesTitle: 'Lyrics Sources',
lyricsSourcesDesc: 'Choose which sources to query for lyrics and in what order. Drag to reorder. Disabled sources are skipped entirely.',
lyricsSourceServer: 'Server',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
downloadsTitle: 'ZIP Export & Archiving',
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
downloadsDefault: 'Default Downloads Folder',
@@ -498,7 +566,6 @@ export const enTranslation = {
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A modern desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Built on Tauri v2 with a native Rust audio engine — lightweight and fast, yet packed with features: waveform seekbar, synchronized lyrics, Last.fm integration, 10-band EQ, crossfade, gapless playback, Replay Gain, genre browsing, and a large library of themes.',
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Synced lyrics · 10-band EQ · Crossfade & Gapless · Replay Gain · Genres · 63 themes · 5 languages',
aboutLicense: 'License',
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
aboutRepo: 'Source Code on GitHub',
@@ -525,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',
@@ -564,6 +633,7 @@ export const enTranslation = {
shortcutSeekForward: 'Seek forward 10s',
shortcutSeekBackward: 'Seek backward 10s',
shortcutToggleQueue: 'Toggle queue',
shortcutOpenFolderBrowser: 'Open {{folderBrowser}}',
shortcutFullscreenPlayer: 'Fullscreen player',
shortcutNativeFullscreen: 'Native fullscreen',
playbackTitle: 'Playback',
@@ -572,6 +642,8 @@ export const enTranslation = {
replayGainMode: 'Mode',
replayGainTrack: 'Track',
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (tagged files)',
replayGainFallback: 'Fallback (untagged / radio)',
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}} s',
@@ -591,6 +663,10 @@ export const enTranslation = {
infiniteQueue: 'Infinite Queue',
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
experimental: 'Experimental',
fsPlayerSection: 'Fullscreen Player',
fsShowArtistPortrait: 'Show artist photo',
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
fsPortraitDim: 'Photo dimming',
seekbarStyle: 'Seekbar Style',
seekbarStyleDesc: 'Choose the look of the player seek bar',
seekbarWaveform: 'Waveform',
@@ -611,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',
},
@@ -650,7 +733,7 @@ export const enTranslation = {
q12: 'How do I change the theme?',
a12: 'Settings → Theme. Choose from a large selection of themes across 8 groups: Psysonic Themes, Mediaplayer, Operating Systems, Games, Movies, Series, Social Media, and Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox).',
q13: 'How do I change the language?',
a13: 'Settings → Language. English, German, French, Dutch, Chinese, Norwegian, and Russian are supported.',
a13: 'Settings → Language. English, German, Spanish, French, Dutch, Chinese, Norwegian, and Russian are supported.',
q15: 'How do I set a download folder?',
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
s5: 'Scrobbling',
@@ -717,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',
@@ -748,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',
@@ -757,6 +872,7 @@ export const enTranslation = {
genreDistribution: 'Genre Distribution (Top 20)',
loadMore: 'Load more',
statArtists: 'Artists',
statArtistsTooltip: 'Album artists only — artists appearing only as a track-level artist (featured, guest, etc.) without their own album are not included.',
statAlbums: 'Albums',
statSongs: 'Songs',
statGenres: 'Genres',
@@ -872,6 +988,7 @@ export const enTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'New suggestions',
addSong: 'Add to playlist',
addSelected: 'Add selected',
cacheOffline: 'Cache playlist offline',
offlineCached: 'Playlist cached',
removeOffline: 'Remove from offline cache',
@@ -890,6 +1007,51 @@ export const enTranslation = {
coverUpdated: 'Cover updated',
metaSaved: 'Playlist updated',
downloadZip: 'Download (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} songs added to {{playlist}}',
addPartial: '{{added}} added, {{skipped}} skipped (duplicates)',
addAllSkipped: 'All skipped ({{count}} duplicates)',
addError: 'Error adding songs',
createAndAddSuccess: 'Playlist "{{playlist}}" created with {{count}} songs',
createError: 'Error creating playlist',
deleteSuccess: '{{count}} playlists deleted',
deleteFailed: 'Error deleting {{name}}',
deleteSelected: 'Delete selected',
mergeSuccess: '{{count}} songs merged into {{playlist}}',
mergeNoNewSongs: 'No new songs to add',
mergeError: 'Error merging playlists',
mergeInto: 'Merge into',
selectionCount: '{{count}} selected',
select: 'Select',
startSelect: 'Enable selection',
cancelSelect: 'Cancel',
loadingAlbums: 'Resolving {{count}} albums…',
loadingArtists: 'Resolving {{count}} artists…',
myPlaylists: 'My Playlists',
noOtherPlaylists: 'No other playlists available',
addToPlaylistSuccess: '{{count}} songs added to {{playlist}}',
addToPlaylistNoNew: 'No new songs to add to {{playlist}}',
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',
@@ -943,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',
},
};
+1182
View File
File diff suppressed because it is too large Load Diff
+250 -13
View File
@@ -5,6 +5,7 @@ export const frTranslation = {
newReleases: 'Nouveautés',
allAlbums: 'Tous les albums',
randomAlbums: 'Albums aléatoires',
randomPicker: 'Créer un mix',
artists: 'Artistes',
randomMix: 'Mix aléatoire',
favorites: 'Favoris',
@@ -16,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',
@@ -23,8 +25,11 @@ 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',
collapsePlaylists: 'Réduire les playlists',
},
home: {
hero: 'En vedette',
@@ -96,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 dabord la lecture, puis relancez la commande de mix.',
lfmLove: 'Aimer sur Last.fm',
lfmUnlove: 'Ne plus aimer sur Last.fm',
favorite: 'Favori',
@@ -105,10 +111,14 @@ export const frTranslation = {
unfavoriteArtist: 'Retirer l\'artiste des favoris',
unfavoriteAlbum: 'Retirer l\'album des favoris',
removeFromQueue: 'Retirer de la file',
removeFromPlaylist: 'Retirer de la playlist',
openAlbum: 'Ouvrir l\'album',
goToArtist: 'Aller à l\'artiste',
download: 'Télécharger (ZIP)',
addToPlaylist: 'Ajouter à la playlist',
selectedPlaylists: '{{count}} playlists sélectionnées',
selectedAlbums: '{{count}} albums sélectionnés',
selectedArtists: '{{count}} artistes sélectionnés',
songInfo: 'Infos du morceau',
},
albumDetail: {
@@ -135,6 +145,7 @@ export const frTranslation = {
goToArtist: 'Aller à {{artist}}',
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
trackTitle: 'Titre',
trackAlbum: 'Album',
trackArtist: 'Artiste',
trackGenre: 'Genre',
trackFormat: 'Format',
@@ -143,11 +154,17 @@ export const frTranslation = {
trackDuration: 'Durée',
trackTotal: 'Total',
columns: 'Colonnes',
resetColumns: 'Réinitialiser',
notFound: 'Album introuvable.',
bioModal: 'Biographie de l\'artiste',
bioClose: 'Fermer',
ratingLabel: 'Note',
enlargeCover: 'Agrandir',
filterSongs: 'Filtrer…',
sortNatural: 'Naturel',
sortByTitle: 'AZ (Titre)',
sortByArtist: 'AZ (Artiste)',
sortByAlbum: 'AZ (Album)',
},
entityRating: {
albumShort: 'Note de lalbum',
@@ -196,6 +213,18 @@ 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',
mixByTracks: 'Mix par pistes',
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
mixByAlbums: 'Mix par albums',
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes',
},
randomAlbums: {
title: 'Albums aléatoires',
@@ -217,6 +246,8 @@ export const frTranslation = {
title: 'Mix aléatoire',
remix: 'Remixer',
remixTooltip: 'Charger de nouveaux morceaux aléatoires',
remixGenre: 'Remixer {{genre}}',
remixTooltipGenre: 'Charger de nouveaux morceaux {{genre}}',
playAll: 'Tout lire',
trackTitle: 'Titre',
trackArtist: 'Artiste',
@@ -238,6 +269,7 @@ export const frTranslation = {
blacklistToggle: 'Filtre par mot-clé',
genreMixTitle: 'Mix par genre',
genreMixDesc: 'Top 20 genres par nombre de morceaux — cliquer pour un mix aléatoire',
genreMixAll: 'Tous les morceaux',
genreMixLoadMore: 'Charger 10 de plus',
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
shuffleGenres: 'Afficher d\'autres genres',
@@ -255,17 +287,17 @@ export const frTranslation = {
yearTo: 'À',
yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
selectionCount: '{{count}} sélectionnés',
downloadZips: 'Télécharger les ZIPs',
addOffline: 'Ajouter hors ligne',
downloadingZip: 'Téléchargement {{current}}/{{total}} : {{name}}',
downloadZipDone: '{{count}} ZIP(s) téléchargé(s)',
downloadZipFailed: 'Échec du téléchargement de {{name}}',
offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…',
offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne',
},
artists: {
title: 'Artistes',
@@ -279,13 +311,18 @@ export const frTranslation = {
notFound: 'Aucun artiste trouvé.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} albums',
selectionCount: '{{count}} sélectionnés',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
addToPlaylist: 'Ajouter à la playlist',
},
login: {
subtitle: 'Votre lecteur de bureau Navidrome',
serverName: 'Nom du serveur (facultatif)',
serverNamePlaceholder: 'Mon Navidrome',
serverUrl: 'URL du serveur',
serverUrlPlaceholder: '192.168.1.100:4533 ou music.exemple.com',
serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com',
username: 'Nom d\'utilisateur',
usernamePlaceholder: 'admin',
password: 'Mot de passe',
@@ -309,6 +346,7 @@ export const frTranslation = {
offlineTitle: 'Pas de connexion au serveur',
offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.',
offlineModeBanner: 'Mode hors ligne — lecture depuis le cache local',
offlineNoCacheBanner: 'Pas de connexion au serveur — {{server}} inaccessible',
offlineLibraryTitle: 'Bibliothèque hors ligne',
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
offlineAlbumCount: '{{n}} album',
@@ -318,6 +356,11 @@ export const frTranslation = {
offlineFilterPlaylists: 'Playlists',
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',
},
@@ -345,7 +388,9 @@ 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',
bulkRemoveFromPlaylist: 'Retirer de la playlist',
bulkClear: 'Désélectionner',
@@ -366,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',
@@ -383,6 +432,7 @@ export const frTranslation = {
servers: 'Serveurs',
serverName: 'Nom du serveur',
serverUrl: 'URL du serveur',
serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com',
serverUsername: 'Nom d\'utilisateur',
serverPassword: 'Mot de passe',
addServer: 'Ajouter un serveur',
@@ -467,6 +517,13 @@ export const frTranslation = {
hotCacheDebounceImmediate: 'Immédiat',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Vider le cache à chaud',
audioOutputDevice: 'Périphérique de sortie audio',
audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.',
audioOutputDeviceDefault: 'Défaut système',
audioOutputDeviceRefresh: 'Actualiser la liste',
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle',
hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la lecture haute résolution native',
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
@@ -480,12 +537,22 @@ 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',
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
enableNeteaselyrics: 'Paroles Netease Cloud Music',
enableNeteaselyricsDesc: 'Utiliser Netease Cloud Music en dernier recours lorsque le serveur et LRCLIB ne retournent rien. Meilleure couverture pour la musique asiatique et internationale.',
lyricsSourcesTitle: 'Sources de paroles',
lyricsSourcesDesc: 'Choisissez quelles sources interroger et dans quel ordre. Glissez pour réordonner. Les sources désactivées sont ignorées.',
lyricsSourceServer: 'Serveur',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
downloadsTitle: 'Export ZIP & Archivage',
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
downloadsDefault: 'Dossier de téléchargement par défaut',
@@ -495,7 +562,6 @@ export const frTranslation = {
logout: 'Se déconnecter',
aboutTitle: 'À propos de Psysonic',
aboutDesc: 'Un lecteur de musique de bureau moderne pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Construit sur Tauri v2 avec un moteur audio Rust natif — léger et rapide, mais riche en fonctionnalités : barre waveform, paroles synchronisées, intégration Last.fm, égaliseur 10 bandes, crossfade, lecture sans blanc, Replay Gain, navigation par genre et une grande bibliothèque de thèmes.',
aboutFeatures: 'Multi-serveur · Scrobbling & love Last.fm · Ambient Stage plein écran · Waveform seek · Paroles synchro · EQ 10 bandes · Crossfade & Gapless · Replay Gain · Genres · 63 thèmes · 5 langues',
aboutLicense: 'Licence',
aboutLicenseText: 'GNU GPL v3 — libre d\'utilisation, de modification et de distribution sous la même licence.',
aboutRepo: 'Code source sur GitHub',
@@ -522,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',
@@ -538,6 +606,7 @@ export const frTranslation = {
shortcutSeekForward: 'Avancer de 10s',
shortcutSeekBackward: 'Reculer de 10s',
shortcutToggleQueue: 'Afficher/masquer la file',
shortcutOpenFolderBrowser: 'Ouvrir {{folderBrowser}}',
shortcutFullscreenPlayer: 'Lecteur plein écran',
shortcutNativeFullscreen: 'Plein écran natif',
tabSystem: 'Système',
@@ -569,6 +638,8 @@ export const frTranslation = {
replayGainMode: 'Mode',
replayGainTrack: 'Piste',
replayGainAlbum: 'Album',
replayGainPreGain: 'Pré-Gain (fichiers taggés)',
replayGainFallback: 'Repli (sans tags / radio)',
crossfade: 'Fondu enchaîné',
crossfadeDesc: 'Fondu entre les pistes',
crossfadeSecs: '{{n}} s',
@@ -588,6 +659,10 @@ export const frTranslation = {
infiniteQueue: 'File infinie',
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
experimental: 'Expérimental',
fsPlayerSection: 'Lecteur plein écran',
fsShowArtistPortrait: "Afficher la photo de l'artiste",
fsShowArtistPortraitDesc: "Afficher la photo de l'artiste (ou la pochette) sur le côté droit du lecteur plein écran.",
fsPortraitDim: "Assombrissement de la photo",
seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
seekbarWaveform: 'Forme d\'onde',
@@ -608,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',
},
@@ -714,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',
@@ -745,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',
@@ -754,6 +868,7 @@ export const frTranslation = {
genreDistribution: 'Répartition par genre (Top 20)',
loadMore: 'Charger plus',
statArtists: 'Artistes',
statArtistsTooltip: 'Artistes d\'album uniquement — les artistes apparaissant seulement sur des pistes (featuring, invité, etc.) sans album propre ne sont pas comptés.',
statAlbums: 'Albums',
statSongs: 'Morceaux',
statGenres: 'Genres',
@@ -869,6 +984,7 @@ export const frTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Nouvelles suggestions',
addSong: 'Ajouter à la playlist',
addSelected: 'Ajouter la sélection',
cacheOffline: 'Mettre la playlist hors ligne',
offlineCached: 'Playlist en cache',
removeOffline: 'Retirer du cache hors ligne',
@@ -887,6 +1003,51 @@ export const frTranslation = {
coverUpdated: 'Pochette mise à jour',
metaSaved: 'Playlist mise à jour',
downloadZip: 'Télécharger (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
addPartial: '{{added}} ajoutés, {{skipped}} ignorés (doublons)',
addAllSkipped: 'Tous ignorés ({{count}} doublons)',
addError: 'Erreur lors de l\'ajout des morceaux',
createAndAddSuccess: 'Playlist "{{playlist}}" créée avec {{count}} morceaux',
createError: 'Erreur lors de la création de la playlist',
deleteSuccess: '{{count}} playlists supprimées',
deleteFailed: 'Erreur lors de la suppression de {{name}}',
deleteSelected: 'Supprimer la sélection',
mergeSuccess: '{{count}} morceaux fusionnés dans {{playlist}}',
mergeNoNewSongs: 'Aucun nouveau morceau à ajouter',
mergeError: 'Erreur lors de la fusion des playlists',
mergeInto: 'Fusionner dans',
selectionCount: '{{count}} sélectionnés',
select: 'Sélectionner',
startSelect: 'Activer la sélection',
cancelSelect: 'Annuler',
loadingAlbums: 'Résolution de {{count}} albums…',
loadingArtists: 'Résolution de {{count}} artistes…',
myPlaylists: 'Mes Playlists',
noOtherPlaylists: 'Aucune autre playlist disponible',
addToPlaylistSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
addToPlaylistNoNew: 'Aucun nouveau morceau pour {{playlist}}',
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',
@@ -897,6 +1058,9 @@ export const frTranslation = {
sortLeast: 'Moins joués en premier',
loadMore: 'Charger plus d\'albums',
noData: 'Aucune donnée d\'écoute. Commencez à écouter !',
noArtists: 'Tous les artistes filtrés.',
filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Masquer les compilations',
},
radio: {
title: 'Radio Internet',
@@ -937,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',
},
};
+251 -14
View File
@@ -5,6 +5,7 @@ export const nbTranslation = {
newReleases: 'Nye utgivelser',
allAlbums: 'Alle album',
randomAlbums: 'Tilfeldige album',
randomPicker: 'Lag en miks',
artists: 'Artister',
randomMix: 'Tilfeldig miks',
favorites: 'Favoritter',
@@ -16,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',
@@ -23,8 +25,11 @@ export const nbTranslation = {
mostPlayed: 'Mest spilt',
radio: 'Internettradio',
folderBrowser: 'Mappeleser',
deviceSync: 'Enhetssynk',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister',
},
home: {
hero: 'Utvalgt',
@@ -96,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',
@@ -105,10 +111,14 @@ export const nbTranslation = {
unfavoriteArtist: 'Fjern artist fra favoritter',
unfavoriteAlbum: 'Fjern album fra favoritter',
removeFromQueue: 'Fjern fra kø',
removeFromPlaylist: 'Fjern fra spilleliste',
openAlbum: 'Åpne album',
goToArtist: 'Gå til artist',
download: 'Last ned (ZIP)',
addToPlaylist: 'Legg til i spilleliste',
selectedPlaylists: '{{count}} spillelister valgt',
selectedAlbums: '{{count}} album valgt',
selectedArtists: '{{count}} artister valgt',
songInfo: 'Sanginfo',
},
albumDetail: {
@@ -135,6 +145,7 @@ export const nbTranslation = {
goToArtist: 'Gå til {{artist}}',
moreLabelAlbums: 'Flere album på {{label}}',
trackTitle: 'Tittel',
trackAlbum: 'Album',
trackArtist: 'Artist',
trackGenre: 'Sjanger',
trackFormat: 'Format',
@@ -143,11 +154,17 @@ export const nbTranslation = {
trackDuration: 'Varighet',
trackTotal: 'Totalt',
columns: 'Kolonner',
resetColumns: 'Tilbakestill',
notFound: 'Album ble ikke funnet.',
bioModal: 'Artistbiografi',
bioClose: 'Lukk',
ratingLabel: 'Vurdering',
enlargeCover: 'Forstørr',
filterSongs: 'Filtrer sanger…',
sortNatural: 'Naturlig',
sortByTitle: 'A–Å (Tittel)',
sortByArtist: 'A–Å (Artist)',
sortByAlbum: 'A–Å (Album)',
},
entityRating: {
albumShort: 'Albumvurdering',
@@ -195,7 +212,19 @@ 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',
mixByTracks: 'Miks etter spor',
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
mixByAlbums: 'Miks etter album',
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
},
randomAlbums: {
title: 'Tilfeldige album',
@@ -217,6 +246,8 @@ export const nbTranslation = {
title: 'Tilfeldig miks',
remix: 'Remiks',
remixTooltip: 'Last inn nye tilfeldige sanger',
remixGenre: 'Remiks {{genre}}',
remixTooltipGenre: 'Last inn nye {{genre}}-sanger',
playAll: 'Spill alt',
trackTitle: 'Tittel',
trackArtist: 'Artist',
@@ -238,6 +269,7 @@ export const nbTranslation = {
blacklistToggle: 'Nøkkelordfilter',
genreMixTitle: 'Sjangermiks',
genreMixDesc: 'Topp 20 sjangre etter antall sanger - klikk for å laste en tilfeldig miks',
genreMixAll: 'Alle sanger',
genreMixLoadMore: 'Last 10 til',
genreMixNoGenres: 'Ingen sjangre funnet på tjeneren.',
shuffleGenres: 'Vis andre sjangre',
@@ -255,17 +287,17 @@ export const nbTranslation = {
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
selectionCount: '{{count}} valgt',
downloadZips: 'Last ned ZIPs',
addOffline: 'Legg til offline',
downloadingZip: 'Laster ned {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) lastet ned',
downloadZipFailed: 'Kunne ikke laste ned {{name}}',
offlineQueuing: 'Legger {{count}} album i kø for offline…',
offlineFailed: 'Kunne ikke legge til {{name}} offline',
},
artists: {
title: 'Artister',
@@ -279,13 +311,18 @@ export const nbTranslation = {
notFound: 'Ingen artister funnet.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} album',
selectionCount: '{{count}} valgt',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
addToPlaylist: 'Legg til i spilleliste',
},
login: {
subtitle: 'Din Navidrome-mediaspiller',
serverName: 'Tjenernavn (valgfritt)',
serverNamePlaceholder: 'Mitt mediabibliotek',
serverUrl: 'Tjener-URL',
serverUrlPlaceholder: '192.168.1.100:4533 eller feks. musikk.eksempel.com',
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
username: 'Brukernavn',
usernamePlaceholder: 'admin',
password: 'Passord',
@@ -309,6 +346,7 @@ export const nbTranslation = {
offlineTitle: 'Ingen tjenertilkobling',
offlineSubtitle: 'Kan ikke nå {{server}}. Sjekk nettverket eller tjeneren din.',
offlineModeBanner: 'Frakoblet modus - spiller fra lokal hurtigbuffer',
offlineNoCacheBanner: 'Ingen servertilkobling — kan ikke nå {{server}}',
offlineLibraryTitle: 'Frakoblet bibliotek',
offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".',
offlineAlbumCount: '{{n}} album',
@@ -318,6 +356,11 @@ export const nbTranslation = {
offlineFilterPlaylists: 'Spillelister',
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',
},
@@ -345,7 +388,9 @@ 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',
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
bulkClear: 'Tøm utvalg',
@@ -366,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',
@@ -383,6 +432,7 @@ export const nbTranslation = {
servers: 'Tjenere',
serverName: 'Tjenernavn',
serverUrl: 'Tjener-URL',
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
serverUsername: 'Brukernavn',
serverPassword: 'Passord',
addServer: 'Legg til tjener',
@@ -468,6 +518,13 @@ export const nbTranslation = {
hotCacheDebounceImmediate: 'Umiddelbart',
hotCacheDebounceSeconds: '{{n}} sek',
hotCacheClearBtn: 'Tøm varm buffer',
audioOutputDevice: 'Lydutgangsenhet',
audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.',
audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
@@ -479,12 +536,22 @@ 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',
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.',
lyricsSourcesTitle: 'Sangtekstkilder',
lyricsSourcesDesc: 'Velg hvilke kilder som skal brukes og i hvilken rekkefølge. Dra for å sortere. Deaktiverte kilder hoppes over.',
lyricsSourceServer: 'Tjener',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
downloadsTitle: 'ZIP Eksport & Arkivering',
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
downloadsDefault: 'Standard nedlastingsmappe',
@@ -494,7 +561,6 @@ export const nbTranslation = {
logout: 'Logg ut',
aboutTitle: 'Om Psysonic',
aboutDesc: 'En moderne musikkspiller for Subsonic-kompatible tjenere (Navidrome, Gonic og andre). Bygget på Tauri v2 med en innebygd Rust-lydmotor - lett og rask, men fullpakket med funksjoner: bølgeform-søkelinje, synkroniserte sangtekster, Last.fm-integrasjon, 10-bånds jevnstiller, crossfade, gapless-avspilling, Replay Gain, sjangerlesing og et stort bibliotek med temaer.',
aboutFeatures: 'Multi-tjener · Last.fm scrobbling og liker · Fullskjerm Ambient Stage · Bølgeformsøk · Synkroniserte tekster · 10-bånds jevnstiller · Crossfade og gapless · Replay Gain · Sjangre · 63 temaer · 6 språk',
aboutLicense: 'Lisens',
aboutLicenseText: 'GNU GPL v3 - gratis å bruke, endre og distribuere under samme lisens.',
aboutRepo: 'Kildekode på GitHub',
@@ -521,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',
@@ -560,6 +628,7 @@ export const nbTranslation = {
shortcutSeekForward: 'Søk fremover 10 sekunder',
shortcutSeekBackward: 'Søk bakover 10 sekunder',
shortcutToggleQueue: 'Veksle kø',
shortcutOpenFolderBrowser: 'Åpne {{folderBrowser}}',
shortcutFullscreenPlayer: 'Fullskjermsspiller',
shortcutNativeFullscreen: 'Naturlig fullskjerm',
playbackTitle: 'Avspilling',
@@ -568,6 +637,8 @@ export const nbTranslation = {
replayGainMode: 'Modus',
replayGainTrack: 'Spor',
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (taggede filer)',
replayGainFallback: 'Reserveverdi (uten tagger / radio)',
crossfade: 'Crossfade',
crossfadeDesc: 'Tone mellom spor',
crossfadeSecs: '{{n}}s',
@@ -587,6 +658,10 @@ export const nbTranslation = {
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
fsPlayerSection: 'Fullskjermspiller',
fsShowArtistPortrait: 'Vis artistbilde',
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
fsPortraitDim: 'Mørklegging av bilde',
seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
seekbarWaveform: 'Bølgeform',
@@ -607,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',
},
@@ -713,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ø',
@@ -744,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',
@@ -753,6 +867,7 @@ export const nbTranslation = {
genreDistribution: 'Sjangerfordeling (Topp 20)',
loadMore: 'Last inn mer',
statArtists: 'Artister',
statArtistsTooltip: 'Kun albumartister — artister som bare opptrer som sporartist (featuring, gjest, osv.) uten eget album telles ikke med.',
statAlbums: 'Album',
statSongs: 'Sanger',
statGenres: 'Sjangere',
@@ -868,6 +983,7 @@ export const nbTranslation = {
titleBadge: 'Spilleliste',
refreshSuggestions: 'Nye forslag',
addSong: 'Legg til i spilleliste',
addSelected: 'Legg til valgte',
cacheOffline: 'Bufre spilleliste offline',
offlineCached: 'Spilleliste bufret',
removeOffline: 'Fjern fra offline-buffer',
@@ -886,6 +1002,51 @@ export const nbTranslation = {
coverUpdated: 'Omslag oppdatert',
metaSaved: 'Spillelisten er oppdatert',
downloadZip: 'Last ned (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} sanger lagt til i {{playlist}}',
addPartial: '{{added}} lagt til, {{skipped}} hoppet over (duplikater)',
addAllSkipped: 'Alle hoppet over ({{count}} duplikater)',
addError: 'Feil ved å legge til sanger',
createAndAddSuccess: 'Spilleliste "{{playlist}}" opprettet med {{count}} sanger',
createError: 'Feil ved oppretting av spilleliste',
deleteSuccess: '{{count}} spillelister slettet',
deleteFailed: 'Feil ved sletting av {{name}}',
deleteSelected: 'Slett valgte',
mergeSuccess: '{{count}} sanger slått sammen i {{playlist}}',
mergeNoNewSongs: 'Ingen nye sanger å legge til',
mergeError: 'Feil ved sammenslåing av spillelister',
mergeInto: 'Slå sammen i',
selectionCount: '{{count}} valgt',
select: 'Velg',
startSelect: 'Aktiver valg',
cancelSelect: 'Avbryt',
loadingAlbums: 'Løser {{count}} album…',
loadingArtists: 'Løser {{count}} artister…',
myPlaylists: 'Mine spillelister',
noOtherPlaylists: 'Ingen andre spillelister tilgjengelig',
addToPlaylistSuccess: '{{count}} sanger lagt til i {{playlist}}',
addToPlaylistNoNew: 'Ingen nye sanger for {{playlist}}',
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',
@@ -896,6 +1057,9 @@ export const nbTranslation = {
sortLeast: 'Minst spilt først',
loadMore: 'Last inn flere album',
noData: 'Ingen avspillingsdata ennå. Begynn å høre!',
noArtists: 'Alle artister filtrert bort.',
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Skjul kompilasjoner',
},
radio: {
title: 'Internettradio',
@@ -936,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',
},
};
+249 -13
View File
@@ -5,6 +5,7 @@ export const nlTranslation = {
newReleases: 'Nieuw',
allAlbums: 'Alle albums',
randomAlbums: 'Willekeurige albums',
randomPicker: 'Mix samenstellen',
artists: 'Artiesten',
randomMix: 'Willekeurige mix',
favorites: 'Favorieten',
@@ -16,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',
@@ -23,8 +25,11 @@ export const nlTranslation = {
mostPlayed: 'Meest gespeeld',
radio: 'Internetradio',
folderBrowser: 'Mappenverkenner',
deviceSync: 'Apparaatsync',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen',
},
home: {
hero: 'Uitgelicht',
@@ -96,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',
@@ -109,6 +115,9 @@ export const nlTranslation = {
goToArtist: 'Naar artiest',
download: 'Downloaden (ZIP)',
addToPlaylist: 'Toevoegen aan playlist',
selectedPlaylists: '{{count}} playlists geselecteerd',
selectedAlbums: '{{count}} albums geselecteerd',
selectedArtists: '{{count}} artiesten geselecteerd',
songInfo: 'Nummerinfo',
},
albumDetail: {
@@ -135,6 +144,7 @@ export const nlTranslation = {
goToArtist: 'Naar {{artist}}',
moreLabelAlbums: 'Meer albums op {{label}}',
trackTitle: 'Titel',
trackAlbum: 'Album',
trackArtist: 'Artiest',
trackGenre: 'Genre',
trackFormat: 'Formaat',
@@ -143,11 +153,17 @@ export const nlTranslation = {
trackDuration: 'Duur',
trackTotal: 'Totaal',
columns: 'Kolommen',
resetColumns: 'Standaard herstellen',
notFound: 'Album niet gevonden.',
bioModal: 'Artiest biografie',
bioClose: 'Sluiten',
ratingLabel: 'Beoordeling',
enlargeCover: 'Vergroten',
filterSongs: 'Filteren…',
sortNatural: 'Natuurlijk',
sortByTitle: 'AZ (Titel)',
sortByArtist: 'AZ (Artiest)',
sortByAlbum: 'AZ (Album)',
},
entityRating: {
albumShort: 'Albumbeoordeling',
@@ -196,6 +212,18 @@ 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',
mixByTracks: 'Mix op nummers',
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
mixByAlbums: 'Mix op albums',
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
},
randomAlbums: {
title: 'Willekeurige albums',
@@ -217,6 +245,8 @@ export const nlTranslation = {
title: 'Willekeurige mix',
remix: 'Opnieuw mixen',
remixTooltip: 'Nieuwe willekeurige nummers laden',
remixGenre: '{{genre}} mixen',
remixTooltipGenre: 'Nieuwe {{genre}}-nummers laden',
playAll: 'Alles afspelen',
trackTitle: 'Titel',
trackArtist: 'Artiest',
@@ -238,6 +268,7 @@ export const nlTranslation = {
blacklistToggle: 'Trefwoordfilter',
genreMixTitle: 'Genremix',
genreMixDesc: 'Top 20 genres op aantal nummers — klik voor een willekeurige mix',
genreMixAll: 'Alle nummers',
genreMixLoadMore: '10 meer laden',
genreMixNoGenres: 'Geen genres gevonden op server.',
shuffleGenres: 'Andere genres tonen',
@@ -255,17 +286,17 @@ export const nlTranslation = {
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
selectionCount: '{{count}} geselecteerd',
downloadZips: 'ZIPs downloaden',
addOffline: 'Offline toevoegen',
downloadingZip: 'Downloaden {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) gedownload',
downloadZipFailed: 'Downloaden van {{name}} mislukt',
offlineQueuing: '{{count}} album(s) in wachtrij voor offline…',
offlineFailed: 'Toevoegen van {{name}} offline mislukt',
},
artists: {
title: 'Artiesten',
@@ -279,13 +310,18 @@ export const nlTranslation = {
notFound: 'Geen artiesten gevonden.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} albums',
selectionCount: '{{count}} geselecteerd',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
addToPlaylist: 'Toevoegen aan playlist',
},
login: {
subtitle: 'Jouw Navidrome-desktopspeler',
serverName: 'Servernaam (optioneel)',
serverNamePlaceholder: 'Mijn Navidrome',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 of music.voorbeeld.nl',
serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl',
username: 'Gebruikersnaam',
usernamePlaceholder: 'admin',
password: 'Wachtwoord',
@@ -309,6 +345,7 @@ export const nlTranslation = {
offlineTitle: 'Geen serververbinding',
offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.',
offlineModeBanner: 'Offline modus — afspelen vanuit lokale cache',
offlineNoCacheBanner: 'Geen serververbinding — {{server}} niet bereikbaar',
offlineLibraryTitle: 'Offline bibliotheek',
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
offlineAlbumCount: '{{n}} album',
@@ -318,6 +355,11 @@ export const nlTranslation = {
offlineFilterPlaylists: 'Afspeellijsten',
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',
},
@@ -345,7 +387,9 @@ export const nlTranslation = {
filterSearchGenres: 'Genres zoeken…',
filterNoGenres: 'Geen genres gevonden',
filterClear: 'Wissen',
play: 'Afspelen',
bulkSelected: '{{count}} geselecteerd',
clearSelection: 'Selectie wissen',
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
bulkClear: 'Selectie wissen',
@@ -366,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',
@@ -383,6 +431,7 @@ export const nlTranslation = {
servers: 'Servers',
serverName: 'Servernaam',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl',
serverUsername: 'Gebruikersnaam',
serverPassword: 'Wachtwoord',
addServer: 'Server toevoegen',
@@ -467,6 +516,13 @@ export const nlTranslation = {
hotCacheDebounceImmediate: 'Direct',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Warme cache wissen',
audioOutputDevice: 'Audio-uitvoerapparaat',
audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.',
audioOutputDeviceDefault: 'Systeemstandaard',
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst',
hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
@@ -480,12 +536,22 @@ 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',
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
enableNeteaselyrics: 'Netease Cloud Music songteksten',
enableNeteaselyricsDesc: 'Gebruik Netease Cloud Music als laatste bron wanneer server en LRCLIB niets opleveren. Beste dekking voor Aziatische en internationale muziek.',
lyricsSourcesTitle: 'Songtekstbronnen',
lyricsSourcesDesc: 'Kies welke bronnen worden geraadpleegd en in welke volgorde. Sleep om te sorteren. Uitgeschakelde bronnen worden overgeslagen.',
lyricsSourceServer: 'Server',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
downloadsTitle: 'ZIP-export & Archivering',
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
downloadsDefault: 'Standaard downloadmap',
@@ -495,7 +561,6 @@ export const nlTranslation = {
logout: 'Uitloggen',
aboutTitle: 'Over Psysonic',
aboutDesc: 'Een moderne desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Gebouwd op Tauri v2 met een native Rust audio-engine — licht en snel, maar boordevol functies: golfvorm-zoekbalk, gesynchroniseerde songteksten, Last.fm-integratie, 10-bands equalizer, crossfade, naadloos afspelen, Replay Gain, genres en een uitgebreide themabibliotheek.',
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Syncteksten · 10-bands EQ · Crossfade & Gapless · Replay Gain · Genres · 63 thema\'s · 5 talen',
aboutLicense: 'Licentie',
aboutLicenseText: 'GNU GPL v3 — vrij te gebruiken, wijzigen en verspreiden onder dezelfde licentie.',
aboutRepo: 'Broncode op GitHub',
@@ -522,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',
@@ -538,6 +605,7 @@ export const nlTranslation = {
shortcutSeekForward: '10s vooruitspoelen',
shortcutSeekBackward: '10s terugspoelen',
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
shortcutOpenFolderBrowser: '{{folderBrowser}} openen',
shortcutFullscreenPlayer: 'Volledigschermspeler',
shortcutNativeFullscreen: 'Systeemvolledig scherm',
tabSystem: 'Systeem',
@@ -569,6 +637,8 @@ export const nlTranslation = {
replayGainMode: 'Modus',
replayGainTrack: 'Nummer',
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (getagde bestanden)',
replayGainFallback: 'Terugval (zonder tags / radio)',
crossfade: 'Overgang',
crossfadeDesc: 'Fade tussen nummers',
crossfadeSecs: '{{n}} s',
@@ -588,6 +658,10 @@ export const nlTranslation = {
infiniteQueue: 'Oneindige wachtrij',
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
experimental: 'Experimenteel',
fsPlayerSection: 'Volledig scherm speler',
fsShowArtistPortrait: 'Artiestfoto tonen',
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
fsPortraitDim: 'Verduistering foto',
seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
seekbarWaveform: 'Golfvorm',
@@ -608,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',
},
@@ -714,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',
@@ -745,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',
@@ -754,6 +867,7 @@ export const nlTranslation = {
genreDistribution: 'Genreverdeling (Top 20)',
loadMore: 'Meer laden',
statArtists: 'Artiesten',
statArtistsTooltip: 'Alleen albumartiesten — artiesten die enkel als trackartiest voorkomen (featuring, gast, enz.) zonder eigen album worden niet meegeteld.',
statAlbums: 'Albums',
statSongs: 'Nummers',
statGenres: 'Genres',
@@ -869,6 +983,7 @@ export const nlTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Nieuwe suggesties',
addSong: 'Toevoegen aan playlist',
addSelected: 'Geselecteerde toevoegen',
cacheOffline: 'Playlist offline opslaan',
offlineCached: 'Playlist gecached',
removeOffline: 'Verwijder uit offline cache',
@@ -887,6 +1002,51 @@ export const nlTranslation = {
coverUpdated: 'Omslag bijgewerkt',
metaSaved: 'Playlist bijgewerkt',
downloadZip: 'Downloaden (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
addPartial: '{{added}} toegevoegd, {{skipped}} overgeslagen (duplicaten)',
addAllSkipped: 'Alles overgeslagen ({{count}} duplicaten)',
addError: 'Fout bij toevoegen van nummers',
createAndAddSuccess: 'Playlist "{{playlist}}" aangemaakt met {{count}} nummers',
createError: 'Fout bij aanmaken van playlist',
deleteSuccess: '{{count}} playlists verwijderd',
deleteFailed: 'Fout bij verwijderen van {{name}}',
deleteSelected: 'Geselecteerde verwijderen',
mergeSuccess: '{{count}} nummers samengevoegd in {{playlist}}',
mergeNoNewSongs: 'Geen nieuwe nummers om toe te voegen',
mergeError: 'Fout bij samenvoegen van playlists',
mergeInto: 'Samenvoegen in',
selectionCount: '{{count}} geselecteerd',
select: 'Selecteren',
startSelect: 'Selectie inschakelen',
cancelSelect: 'Annuleren',
loadingAlbums: '{{count}} albums oplossen…',
loadingArtists: '{{count}} artiesten oplossen…',
myPlaylists: 'Mijn playlists',
noOtherPlaylists: 'Geen andere playlists beschikbaar',
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',
@@ -897,6 +1057,9 @@ export const nlTranslation = {
sortLeast: 'Minst gespeeld eerst',
loadMore: 'Meer albums laden',
noData: 'Nog geen afspeelgegevens. Begin met luisteren!',
noArtists: 'Alle artiesten gefilterd.',
filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Compilaties verbergen',
},
radio: {
title: 'Internetradio',
@@ -937,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',
},
};
+259 -23
View File
@@ -6,6 +6,7 @@ export const ruTranslation = {
newReleases: 'Новинки',
allAlbums: 'Все альбомы',
randomAlbums: 'Случайные альбомы',
randomPicker: 'Собрать микс',
artists: 'Исполнители',
randomMix: 'Случайный микс',
favorites: 'Избранное',
@@ -17,6 +18,7 @@ export const ruTranslation = {
expand: 'Развернуть боковую панель',
collapse: 'Свернуть боковую панель',
downloadingTracks: 'Кэширование {{n}} треков…',
syncingTracks: 'Синхронизация {{done}}/{{total}}…',
cancelDownload: 'Отменить загрузку',
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
@@ -24,8 +26,11 @@ export const ruTranslation = {
mostPlayed: 'Популярное',
radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
deviceSync: 'Синхронизация устройства',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты',
},
home: {
hero: 'Подборка',
@@ -97,6 +102,7 @@ export const ruTranslation = {
startRadio: 'Радио по похожим',
instantMix: 'Instant Mix',
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.',
lfmLove: 'Любимое на Last.fm',
lfmUnlove: 'Убрать с Last.fm',
favorite: 'В избранное',
@@ -106,10 +112,14 @@ export const ruTranslation = {
unfavoriteArtist: 'Убрать исполнителя из избранного',
unfavoriteAlbum: 'Убрать альбом из избранного',
removeFromQueue: 'Убрать из очереди',
removeFromPlaylist: 'Убрать из плейлиста',
openAlbum: 'Открыть альбом',
goToArtist: 'К исполнителю',
download: 'Скачать (ZIP)',
addToPlaylist: 'В плейлист',
selectedPlaylists: '{{count}} плейлистов выбрано',
selectedAlbums: '{{count}} альбомов выбрано',
selectedArtists: '{{count}} исполнителей выбрано',
songInfo: 'Сведения о треке',
},
albumDetail: {
@@ -137,6 +147,7 @@ export const ruTranslation = {
goToArtist: 'Перейти к {{artist}}',
moreLabelAlbums: 'Другие альбомы на {{label}}',
trackTitle: 'Название',
trackAlbum: 'Альбом',
trackArtist: 'Исполнитель',
trackGenre: 'Жанр',
trackFormat: 'Формат',
@@ -145,11 +156,17 @@ export const ruTranslation = {
trackDuration: 'Длительность',
trackTotal: 'Итого',
columns: 'Колонки',
resetColumns: 'Сбросить',
notFound: 'Альбом не найден.',
bioModal: 'Биография исполнителя',
bioClose: 'Закрыть',
ratingLabel: 'Оценка',
enlargeCover: 'Увеличить обложку',
filterSongs: 'Фильтр треков…',
sortNatural: 'По умолчанию',
sortByTitle: 'А–Я (название)',
sortByArtist: 'А–Я (исполнитель)',
sortByAlbum: 'А–Я (альбом)',
},
entityRating: {
albumShort: 'Оценка альбома',
@@ -200,6 +217,18 @@ export const ruTranslation = {
playAll: 'Воспроизвести всё',
removeSong: 'Убрать из избранного',
stations: 'Радиостанции',
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
showingCount: 'Показано {{filtered}} из {{total}}',
clearArtistFilter: 'Сбросить фильтр исполнителя',
noFilterResults: 'Нет результатов с выбранными фильтрами.',
allArtists: 'Все исполнители',
},
randomLanding: {
title: 'Собрать микс',
mixByTracks: 'Микс по трекам',
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
mixByAlbums: 'Микс по альбомам',
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
},
randomAlbums: {
title: 'Случайные альбомы',
@@ -223,6 +252,8 @@ export const ruTranslation = {
title: 'Случайный микс',
remix: 'Новый микс',
remixTooltip: 'Подобрать другие случайные треки',
remixGenre: 'Новый микс: {{genre}}',
remixTooltipGenre: 'Подобрать другие треки жанра {{genre}}',
playAll: 'Воспроизвести всё',
trackTitle: 'Название',
trackArtist: 'Исполнитель',
@@ -245,6 +276,7 @@ export const ruTranslation = {
blacklistToggle: 'Фильтр по словам',
genreMixTitle: 'Микс по жанру',
genreMixDesc: 'Топ-20 жанров по числу треков — нажмите, чтобы собрать микс',
genreMixAll: 'Все треки',
genreMixLoadMore: 'Ещё 10 жанров',
genreMixNoGenres: 'На сервере нет данных о жанрах.',
shuffleGenres: 'Другие жанры',
@@ -264,17 +296,17 @@ export const ruTranslation = {
yearTo: 'По',
yearFilterClear: 'Сбросить год',
yearFilterLabel: 'Год',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
selectionCount: '{{count}} выбрано',
downloadZips: 'Скачать ZIP-архивы',
addOffline: 'Добавить офлайн',
downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP-архив(ов) загружено',
downloadZipFailed: 'Не удалось скачать {{name}}',
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
offlineFailed: 'Не удалось добавить {{name}} офлайн',
},
artists: {
title: 'Исполнители',
@@ -290,13 +322,18 @@ export const ruTranslation = {
albumCount_few: '{{count}} альбома',
albumCount_many: '{{count}} альбомов',
albumCount_other: '{{count}} альбомов',
selectionCount: '{{count}} выбрано',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
addToPlaylist: 'В плейлист',
},
login: {
subtitle: 'Десктопный клиент для Navidrome',
serverName: 'Название сервера (необязательно)',
serverNamePlaceholder: 'Мой Navidrome',
serverUrl: 'Адрес сервера',
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
username: 'Логин',
usernamePlaceholder: 'admin',
password: 'Пароль',
@@ -320,6 +357,7 @@ export const ruTranslation = {
offlineTitle: 'Нет связи с сервером',
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
offlineNoCacheBanner: 'Нет соединения с сервером — {{server}} недоступен',
offlineLibraryTitle: 'Офлайн-библиотека',
offlineLibraryEmpty:
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
@@ -332,6 +370,11 @@ export const ruTranslation = {
offlineFilterPlaylists: 'Плейлисты',
offlineFilterArtists: 'Дискографии',
retry: 'Повторить',
serverSettings: 'Настройки сервера',
switchServerTitle: 'Сменить сервер',
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
manageServers: 'Управление серверами…',
switchFailed: 'Не удалось переключиться — сервер недоступен.',
lastfmConnected: 'Last.fm: @{{user}}',
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
},
@@ -359,7 +402,9 @@ export const ruTranslation = {
filterSearchGenres: 'Поиск жанров…',
filterNoGenres: 'Нет совпадений',
filterClear: 'Сбросить',
play: 'Воспроизвести',
bulkSelected: 'Выбрано: {{count}}',
clearSelection: 'Сбросить выбор',
bulkAddToPlaylist: 'В плейлист',
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
bulkClear: 'Снять выделение',
@@ -380,6 +425,10 @@ export const ruTranslation = {
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
durationMinutesOnly: '{{minutes}}мин',
updaterOpenGitHub: 'Открыть на GitHub',
filters: 'Фильтры',
more: 'еще',
yearRange: 'Диапазон лет',
clearAll: 'Очистить всё',
},
settings: {
title: 'Настройки',
@@ -398,6 +447,7 @@ export const ruTranslation = {
servers: 'Серверы',
serverName: 'Название',
serverUrl: 'Адрес',
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
serverUsername: 'Логин',
serverPassword: 'Пароль',
addServer: 'Добавить сервер',
@@ -484,9 +534,16 @@ export const ruTranslation = {
hotCacheDebounceImmediate: 'Сразу',
hotCacheDebounceSeconds: '{{n}} с',
hotCacheClearBtn: 'Очистить горячий кэш',
audioOutputDevice: 'Устройство вывода звука',
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
audioOutputDeviceDefault: 'Системное по умолчанию',
audioOutputDeviceRefresh: 'Обновить список устройств',
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
showArtistImages: 'Фото исполнителей',
showArtistImagesDesc:
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
@@ -496,20 +553,30 @@ export const ruTranslation = {
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
useCustomTitlebar: 'Своя строка заголовка',
useCustomTitlebarDesc:
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в Apple Music API для поиска обложки для профиля Discord. По умолчанию отключено из соображений конфиденциальности.',
discordTemplates: 'Пользовательские шаблоны текста',
discordTemplatesDesc: 'Настройте, какая информация отображается в профиле Discord. Переменные: {title}, {artist}, {album}',
discordTemplateDetails: 'Основная строка (details)',
discordTemplateState: 'Вторичная строка (state)',
discordTemplateLargeText: 'Подсказка альбома (largeText)',
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
nowPlayingEnabledDesc:
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
lyricsServerFirst: 'Предпочитать серверные тексты',
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы использовать LRCLIB сначала.',
enableNeteaselyrics: 'Тексты Netease Cloud Music',
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда сервер и LRCLIB не находят ничего. Лучшее покрытие для азиатской и международной музыки.',
downloadsTitle: 'Экспорт ZIP и архивы',
lyricsServerFirst: 'Предпочитать тексты с сервера',
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы сначала использовать LRCLIB.',
enableNeteaselyrics: 'Тексты из Netease Cloud Music',
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда остальные способы не находят ничего. Лучшее покрытие для азиатской и международной музыки.',
lyricsSourcesTitle: 'Источники текстов',
lyricsSourcesDesc: 'Выберите источники для поиска текстов и их порядок. Перетаскивайте для сортировки. Отключённые источники пропускаются.',
lyricsSourceServer: 'Сервер',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
downloadsTitle: 'Экспорт ZIP и архивов',
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
downloadsDefault: 'Папка «Загрузки» по умолчанию',
pickFolder: 'Выбрать',
@@ -519,8 +586,6 @@ export const ruTranslation = {
aboutTitle: 'О Psysonic',
aboutDesc:
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
aboutFeatures:
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
aboutLicense: 'Лицензия',
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
aboutRepo: 'Исходный код на GitHub',
@@ -548,6 +613,8 @@ export const ruTranslation = {
sidebarReset: 'Сбросить порядок',
sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса',
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
tabInput: 'Ввод',
tabServer: 'Сервер',
tabSystem: 'Система',
@@ -589,6 +656,7 @@ export const ruTranslation = {
shortcutSeekForward: 'Вперёд на 10 с',
shortcutSeekBackward: 'Назад на 10 с',
shortcutToggleQueue: 'Показать / скрыть очередь',
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
shortcutFullscreenPlayer: 'Полноэкранный плеер',
shortcutNativeFullscreen: 'Системный полный экран',
playbackTitle: 'Воспроизведение',
@@ -597,6 +665,8 @@ export const ruTranslation = {
replayGainMode: 'Режим',
replayGainTrack: 'По треку',
replayGainAlbum: 'По альбому',
replayGainPreGain: 'Предусиление (файлы с тегами)',
replayGainFallback: 'Резерв (без тегов / радио)',
crossfade: 'Кроссфейд',
crossfadeDesc: 'Плавный переход между треками',
crossfadeSecs: '{{n}} с',
@@ -616,6 +686,10 @@ export const ruTranslation = {
infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально',
fsPlayerSection: 'Полноэкранный плеер',
fsShowArtistPortrait: 'Отображать фото артиста',
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
fsPortraitDim: 'Затемнение фото',
seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
seekbarWaveform: 'Форма волны',
@@ -636,6 +710,13 @@ export const ruTranslation = {
themeSchedulerNightTheme: 'Ночная тема',
themeSchedulerNightStart: 'Ночь начинается в',
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
visualOptionsTitle: 'Визуальные Настройки',
coverArtBackground: 'Фон Обложки',
coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
playlistCoverPhoto: 'Обложка Плейлиста',
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
showBitrate: 'Показывать Битрейт',
showBitrateSub: 'Отображать битрейт аудио в списках треков',
uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб',
},
@@ -765,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: 'Очередь',
@@ -796,6 +903,12 @@ export const ruTranslation = {
trackPlural: 'треков',
showRemaining: 'Осталось',
showTotal: 'Всего',
rgTrack: 'Т {{db}} дБ',
rgAlbum: 'А {{db}} дБ',
rgPeak: 'Пик {{pk}}',
sourceOffline: 'Играет из офлайн-библиотеки',
sourceHot: 'Играет из кэша',
sourceStream: 'Играет из сетевого потока',
},
statistics: {
title: 'Статистика',
@@ -805,6 +918,7 @@ export const ruTranslation = {
genreDistribution: 'Жанры (топ-20)',
loadMore: 'Ещё',
statArtists: 'Исполнители',
statArtistsTooltip: 'Только исполнители альбомов — артисты, присутствующие лишь в треках (фичеринг, гость и т. д.) без собственного альбома, не учитываются.',
statAlbums: 'Альбомы',
statSongs: 'Треки',
statGenres: 'Жанры',
@@ -928,6 +1042,7 @@ export const ruTranslation = {
titleBadge: 'Плейлист',
refreshSuggestions: 'Обновить подборку',
addSong: 'В плейлист',
addSelected: 'Добавить выбранные',
cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён',
removeOffline: 'Удалить из офлайн-кэша',
@@ -946,6 +1061,51 @@ export const ruTranslation = {
coverUpdated: 'Обложка обновлена',
metaSaved: 'Плейлист сохранён',
downloadZip: 'Скачать (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} треков добавлено в {{playlist}}',
addPartial: '{{added}} добавлено, {{skipped}} пропущено (дубликаты)',
addAllSkipped: 'Все пропущены ({{count}} дубликатов)',
addError: 'Ошибка при добавлении треков',
createAndAddSuccess: 'Плейлист "{{playlist}}" создан с {{count}} треками',
createError: 'Ошибка при создании плейлиста',
deleteSuccess: '{{count}} плейлистов удалено',
deleteFailed: 'Ошибка при удалении {{name}}',
deleteSelected: 'Удалить выбранные',
mergeSuccess: '{{count}} треков объединено в {{playlist}}',
mergeNoNewSongs: 'Нет новых треков для добавления',
mergeError: 'Ошибка при объединении плейлистов',
mergeInto: 'Объединить в',
selectionCount: '{{count}} выбрано',
select: 'Выбрать',
startSelect: 'Включить выбор',
cancelSelect: 'Отмена',
loadingAlbums: 'Загрузка {{count}} альбомов…',
loadingArtists: 'Загрузка {{count}} исполнителей…',
myPlaylists: 'Мои плейлисты',
noOtherPlaylists: 'Нет других доступных плейлистов',
addToPlaylistSuccess: '{{count}} треков добавлено в {{playlist}}',
addToPlaylistNoNew: 'Нет новых треков для {{playlist}}',
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: 'Популярное',
@@ -955,7 +1115,10 @@ export const ruTranslation = {
sortMost: 'Сначала популярные',
sortLeast: 'Сначала малоизвестные',
loadMore: 'Загрузить больше альбомов',
noData: 'Нет данных о прослушиваниях. Начните слушать!',
noData: 'Пока нет данных о прослушивании. Начните слушать!',
noArtists: 'Все исполнители отфильтрованы.',
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
filterCompilationsShort: 'Скрыть сборники',
},
radio: {
title: 'Онлайн-радио',
@@ -996,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: 'Случайные альбомы',
},
};
+248 -14
View File
@@ -5,6 +5,7 @@ export const zhTranslation = {
newReleases: '新发布',
allAlbums: '全部专辑',
randomAlbums: '随机专辑',
randomPicker: '创建混音',
artists: '艺术家',
randomMix: '随机混音',
favorites: '收藏夹',
@@ -16,6 +17,7 @@ export const zhTranslation = {
expand: '展开侧边栏',
collapse: '收起侧边栏',
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
syncingTracks: '同步中 {{done}}/{{total}}…',
cancelDownload: '取消下载',
offlineLibrary: '离线音乐库',
genres: '流派',
@@ -23,8 +25,11 @@ export const zhTranslation = {
mostPlayed: '最常播放',
radio: '网络电台',
folderBrowser: '文件夹浏览器',
deviceSync: '设备同步',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表',
},
home: {
hero: '精选',
@@ -96,6 +101,7 @@ export const zhTranslation = {
startRadio: '开始电台',
instantMix: '即时混音',
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
cliMixNeedsTrack: '当前没有正在播放的内容 — 请先开始播放,然后再执行混音命令。',
lfmLove: '在 Last.fm 上标记喜欢',
lfmUnlove: '取消 Last.fm 喜欢标记',
favorite: '收藏',
@@ -109,6 +115,9 @@ export const zhTranslation = {
goToArtist: '前往艺术家',
download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表',
selectedPlaylists: '已选择 {{count}} 个播放列表',
selectedAlbums: '已选择 {{count}} 个专辑',
selectedArtists: '已选择 {{count}} 个艺术家',
songInfo: '歌曲信息',
},
albumDetail: {
@@ -135,6 +144,7 @@ export const zhTranslation = {
goToArtist: '前往 {{artist}}',
moreLabelAlbums: '{{label}} 的更多专辑',
trackTitle: '标题',
trackAlbum: '专辑',
trackArtist: '艺术家',
trackGenre: '流派',
trackFormat: '格式',
@@ -143,11 +153,17 @@ export const zhTranslation = {
trackDuration: '时长',
trackTotal: '总计',
columns: '列',
resetColumns: '重置为默认',
notFound: '未找到专辑。',
bioModal: '艺术家简介',
bioClose: '关闭',
ratingLabel: '评分',
enlargeCover: '放大',
filterSongs: '筛选歌曲…',
sortNatural: '默认顺序',
sortByTitle: 'AZ(标题)',
sortByArtist: 'AZ(艺术家)',
sortByAlbum: 'AZ(专辑)',
},
entityRating: {
albumShort: '专辑评分',
@@ -196,6 +212,18 @@ export const zhTranslation = {
playAll: '全部播放',
removeSong: '从收藏中移除',
stations: '广播电台',
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
showingCount: '显示 {{filtered}} / {{total}}',
clearArtistFilter: '清除艺术家筛选',
noFilterResults: '所选筛选条件下无结果。',
allArtists: '全部艺术家',
},
randomLanding: {
title: '创建混音',
mixByTracks: '按曲目混音',
mixByTracksDesc: '从整个媒体库随机选取曲目',
mixByAlbums: '按专辑混音',
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
},
randomAlbums: {
title: '随机专辑',
@@ -217,6 +245,8 @@ export const zhTranslation = {
title: '随机混音',
remix: '重新混音',
remixTooltip: '加载新的随机歌曲',
remixGenre: '重混 {{genre}}',
remixTooltipGenre: '加载新的{{genre}}歌曲',
playAll: '全部播放',
trackTitle: '标题',
trackArtist: '艺术家',
@@ -238,6 +268,7 @@ export const zhTranslation = {
blacklistToggle: '关键词过滤',
genreMixTitle: '流派混音',
genreMixDesc: '按歌曲数量排列的前 20 个流派 — 点击获取随机混音',
genreMixAll: '所有歌曲',
genreMixLoadMore: '加载 10 首更多',
genreMixNoGenres: '服务器上未找到流派。',
shuffleGenres: '显示其他流派',
@@ -255,17 +286,17 @@ export const zhTranslation = {
yearTo: '到',
yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
selectionCount: '{{count}} 已选择',
downloadZips: '下载 ZIP',
addOffline: '添加离线',
downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}',
downloadZipDone: '已下载 {{count}} ZIP',
downloadZipFailed: '下载 {{name}} 失败',
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
offlineFailed: '添加 {{name}} 离线失败',
},
artists: {
title: '艺术家',
@@ -279,13 +310,18 @@ export const zhTranslation = {
notFound: '未找到艺术家。',
albumCount_one: '{{count}} 张专辑',
albumCount_other: '{{count}} 张专辑',
selectionCount: '{{count}} 已选择',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
addToPlaylist: '添加到播放列表',
},
login: {
subtitle: '您的 Navidrome 桌面播放器',
serverName: '服务器名称(可选)',
serverNamePlaceholder: '我的 Navidrome',
serverUrl: '服务器地址',
serverUrlPlaceholder: '192.168.1.100:4533 或 music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
username: '用户名',
usernamePlaceholder: 'admin',
password: '密码',
@@ -309,11 +345,17 @@ export const zhTranslation = {
offlineTitle: '无服务器连接',
offlineSubtitle: '无法连接到 {{server}}。请检查您的网络或服务器。',
offlineModeBanner: '离线模式 — 正在从本地缓存播放',
offlineNoCacheBanner: '无服务器连接 — 无法访问 {{server}}',
offlineLibraryTitle: '离线音乐库',
offlineLibraryEmpty: '尚未缓存任何专辑。请联网,打开专辑并点击"设为离线可用"。',
offlineAlbumCount_one: '{{n}} 张专辑',
offlineAlbumCount_plural: '{{n}} 张专辑',
retry: '重试',
serverSettings: '服务器设置',
switchServerTitle: '切换服务器',
switchServerHint: '点击选择其他已保存的服务器。',
manageServers: '管理服务器…',
switchFailed: '无法切换 — 无法连接服务器。',
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
lastfmSessionInvalid: '会话无效 — 点击重新连接',
},
@@ -341,7 +383,9 @@ export const zhTranslation = {
filterSearchGenres: '搜索流派…',
filterNoGenres: '未找到匹配流派',
filterClear: '清除',
play: '播放',
bulkSelected: '已选 {{count}} 首',
clearSelection: '清除选择',
bulkAddToPlaylist: '添加到播放列表',
bulkRemoveFromPlaylist: '从播放列表移除',
bulkClear: '取消选择',
@@ -362,6 +406,10 @@ export const zhTranslation = {
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
durationMinutesOnly: '{{minutes}}分钟',
updaterOpenGitHub: '在 GitHub 上打开',
filters: '筛选器',
more: '更多',
yearRange: '年份范围',
clearAll: '清除全部',
},
settings: {
title: '设置',
@@ -379,6 +427,7 @@ export const zhTranslation = {
servers: '服务器',
serverName: '服务器名称',
serverUrl: '服务器地址',
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
serverUsername: '用户名',
serverPassword: '密码',
addServer: '添加服务器',
@@ -463,6 +512,13 @@ export const zhTranslation = {
hotCacheDebounceImmediate: '立即',
hotCacheDebounceSeconds: '{{n}} 秒',
hotCacheClearBtn: '清空热缓存',
audioOutputDevice: '音频输出设备',
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
audioOutputDeviceDefault: '系统默认',
audioOutputDeviceRefresh: '刷新设备列表',
audioOutputDeviceOsDefaultNow: '当前系统输出',
audioOutputDeviceListError: '无法加载音频设备列表。',
audioOutputDeviceNotInCurrentList: '不在当前列表中',
hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
@@ -476,12 +532,22 @@ 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: '优先使用服务器歌词',
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
enableNeteaselyrics: '网易云音乐歌词',
enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。',
lyricsSourcesTitle: '歌词来源',
lyricsSourcesDesc: '选择要查询的歌词来源及其顺序。拖动以排序。已禁用的来源将被跳过。',
lyricsSourceServer: '服务器',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: '网易云音乐',
downloadsTitle: 'ZIP 导出与归档',
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
downloadsDefault: '默认下载文件夹',
@@ -491,7 +557,6 @@ export const zhTranslation = {
logout: '退出登录',
aboutTitle: '关于 Psysonic',
aboutDesc: '适用于 Subsonic 兼容服务器(Navidrome、Gonic 等)的现代桌面音乐播放器。基于 Tauri v2 和原生 Rust 音频引擎构建——轻量快速,功能丰富:波形进度条、同步歌词、Last.fm 集成、10 段均衡器、交叉淡入淡出、无缝播放、响度增益、流派浏览以及大量精美主题。',
aboutFeatures: '多服务器 · Last.fm 记录 & 喜爱 · 全屏环境舞台 · 波形跳转 · 同步歌词 · 10 段 EQ · 交叉淡入淡出 & 无缝播放 · 响度增益 · 流派 · 63 个主题 · 5 种语言',
aboutLicense: '许可证',
aboutLicenseText: 'GNU GPL v3 — 在相同许可证下可自由使用、修改和分发。',
aboutRepo: 'GitHub 上的源代码',
@@ -518,6 +583,8 @@ export const zhTranslation = {
sidebarReset: '重置为默认',
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航',
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
tabInput: '输入',
tabServer: '服务器',
tabSystem: '系统',
@@ -557,6 +624,7 @@ export const zhTranslation = {
shortcutSeekForward: '快进 10 秒',
shortcutSeekBackward: '快退 10 秒',
shortcutToggleQueue: '切换队列',
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
shortcutFullscreenPlayer: '全屏播放器',
shortcutNativeFullscreen: '原生全屏',
playbackTitle: '播放',
@@ -565,6 +633,8 @@ export const zhTranslation = {
replayGainMode: '模式',
replayGainTrack: '曲目',
replayGainAlbum: '专辑',
replayGainPreGain: '预增益(有标签文件)',
replayGainFallback: '回退增益(无标签 / 收音机)',
crossfade: '交叉淡入淡出',
crossfadeDesc: '曲目间淡入淡出',
crossfadeSecs: '{{n}} 秒',
@@ -584,6 +654,10 @@ export const zhTranslation = {
infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性',
fsPlayerSection: '全屏播放器',
fsShowArtistPortrait: '显示艺术家照片',
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
fsPortraitDim: '照片暗化',
seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观',
seekbarWaveform: '波形',
@@ -604,6 +678,13 @@ export const zhTranslation = {
themeSchedulerNightTheme: '夜晚主题',
themeSchedulerNightStart: '夜晚开始时间',
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
visualOptionsTitle: '视觉选项',
coverArtBackground: '封面背景',
coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景',
playlistCoverPhoto: '播放列表封面照片',
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
showBitrate: '显示比特率',
showBitrateSub: '在曲目列表中显示音频比特率',
uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放',
},
@@ -710,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: '队列',
@@ -741,6 +848,12 @@ export const zhTranslation = {
trackPlural: '首曲目',
showRemaining: '显示剩余时间',
showTotal: '显示总时间',
rgTrack: '曲目 {{db}} dB',
rgAlbum: '专辑 {{db}} dB',
rgPeak: '峰值 {{pk}}',
sourceOffline: '正在从离线库播放',
sourceHot: '正在从缓存播放',
sourceStream: '正在从网络流播放',
},
statistics: {
title: '统计',
@@ -750,6 +863,7 @@ export const zhTranslation = {
genreDistribution: '流派分布(前 20',
loadMore: '加载更多',
statArtists: '艺术家',
statArtistsTooltip: '仅限专辑艺术家——仅作为单曲艺术家出现(合唱、客串等)且无自己专辑的艺术家不计入此处。',
statAlbums: '专辑',
statSongs: '歌曲',
statGenres: '流派',
@@ -865,6 +979,7 @@ export const zhTranslation = {
titleBadge: '播放列表',
refreshSuggestions: '新建议',
addSong: '添加到播放列表',
addSelected: '添加所选',
cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存',
removeOffline: '从离线缓存中移除',
@@ -883,6 +998,49 @@ export const zhTranslation = {
coverUpdated: '封面已更新',
metaSaved: '播放列表已更新',
downloadZip: '下载 (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addPartial: '{{added}} 首已添加,{{skipped}} 首跳过(重复)',
addAllSkipped: '全部跳过({{count}} 首重复)',
addError: '添加歌曲时出错',
createAndAddSuccess: '播放列表 "{{playlist}}" 已创建,包含 {{count}} 首歌曲',
createError: '创建播放列表时出错',
deleteSuccess: '{{count}} 个播放列表已删除',
deleteFailed: '删除 {{name}} 时出错',
deleteSelected: '删除所选',
mergeSuccess: '{{count}} 首歌曲已合并到 {{playlist}}',
mergeNoNewSongs: '没有新歌曲可添加',
mergeError: '合并播放列表时出错',
mergeInto: '合并到',
selectionCount: '{{count}} 已选择',
select: '选择',
startSelect: '启用选择',
cancelSelect: '取消',
loadingAlbums: '正在解析 {{count}} 张专辑…',
loadingArtists: '正在解析 {{count}} 位艺术家…',
myPlaylists: '我的播放列表',
noOtherPlaylists: '没有其他可用播放列表',
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: '最常播放',
@@ -892,7 +1050,10 @@ export const zhTranslation = {
sortMost: '最多播放在前',
sortLeast: '最少播放在前',
loadMore: '加载更多专辑',
noData: '暂无播放数据开始听吧!',
noData: '暂无播放数据开始听吧!',
noArtists: '所有艺术家已过滤。',
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
filterCompilationsShort: '隐藏合辑',
},
radio: {
title: '网络电台',
@@ -933,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
View File
@@ -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>
);
+24 -8
View File
@@ -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();
+110 -7
View File
@@ -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';
@@ -57,6 +58,10 @@ export default function AlbumDetail() {
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const [albumEntityRating, setAlbumEntityRating] = useState(0);
const [filterText, setFilterText] = useState('');
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
const albumId = album?.album.id ?? '';
@@ -132,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;
@@ -259,6 +280,65 @@ const handleEnqueueAll = () => {
deleteAlbum(album.album.id, serverId);
};
const handleSort = (key: typeof sortKey) => {
if (key === 'natural') return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
setSortKey(key);
setSortDir('asc');
setSortClickCount(1);
}
};
// Must be before early returns — hooks must be called unconditionally.
const mergedStarredSongs = useMemo(() => new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
]), [starredSongs, starredOverrides]);
const displayedSongs = useMemo(() => {
if (!album) return [];
const q = filterText.trim().toLowerCase();
if (!q && sortKey === 'natural') return album.songs;
let result = [...album.songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
switch (sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
case 'favorite':
av = mergedStarredSongs.has(a.id) ? 1 : 0;
bv = mergedStarredSongs.has(b.id) ? 1 : 0;
break;
case 'rating':
av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
break;
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}, [album, filterText, sortKey, sortDir, mergedStarredSongs, ratings, userRatingOverrides]);
// Hooks must be called unconditionally — derive from nullable album state.
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
@@ -267,12 +347,6 @@ const handleEnqueueAll = () => {
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
// Must be before early returns — hooks must be called unconditionally.
const mergedStarredSongs = useMemo(() => new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
]), [starredSongs, starredOverrides]);
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
@@ -295,6 +369,7 @@ const handleEnqueueAll = () => {
onDownload={handleDownload}
onPlayAll={handlePlayAll}
onEnqueueAll={handleEnqueueAll}
onShuffleAll={handleShuffleAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={resolvedOfflineStatus}
@@ -318,8 +393,33 @@ const handleEnqueueAll = () => {
</div>
)}
{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-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
{filterText && (
<button
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('')}
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
</div>
)}
<AlbumTrackList
songs={songs}
songs={displayedSongs}
sorted={sortKey !== 'natural' || !!filterText.trim()}
hasVariousArtists={hasVariousArtists}
currentTrack={currentTrack}
isPlaying={isPlaying}
@@ -330,6 +430,9 @@ const handleEnqueueAll = () => {
onRate={handleRate}
onToggleSongStar={toggleSongStar}
onContextMenu={openContextMenu}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
/>
{relatedAlbums.length > 0 && (
+4 -1
View File
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -68,6 +69,7 @@ export default function Albums() {
};
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -284,6 +286,7 @@ export default function Albums() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
+132 -53
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
@@ -81,25 +81,55 @@ export default function Artists() {
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [visibleCount, setVisibleCount] = useState(50);
const showArtistImages = useAuthStore(s => s.showArtistImages);
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);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
const loadMore = useCallback(() => {
setVisibleCount(prev => prev + 50);
}, []);
if (loadingMore) return;
setLoadingMore(true);
setVisibleCount(prev => prev + PAGE_SIZE);
setTimeout(() => setLoadingMore(false), 100);
}, [loadingMore, PAGE_SIZE]);
// Reset infinite scroll when filters change
// Reset infinite scroll when filters or image setting change
useEffect(() => {
setVisibleCount(50);
}, [filter, letterFilter, viewMode]);
setVisibleCount(PAGE_SIZE);
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
// Filter pipeline
let filtered = artists;
@@ -120,6 +150,16 @@ export default function Artists() {
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
// Intersection Observer for infinite scroll (after hasMore declaration)
useEffect(() => {
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore, hasMore]);
// Group by first letter (for list view)
const groups: Record<string, SubsonicArtist[]> = {};
visible.forEach(a => {
@@ -134,7 +174,11 @@ export default function Artists() {
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
@@ -146,30 +190,43 @@ export default function Artists() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div>
@@ -179,17 +236,7 @@ export default function Artists() {
<button
key={l}
onClick={() => setLetterFilter(l)}
style={{
padding: '0.25rem 0.5rem',
borderRadius: 'var(--radius-sm)',
background: letterFilter === l ? 'var(--accent)' : 'var(--bg-card)',
color: letterFilter === l ? 'var(--ctp-crust)' : 'var(--text-secondary)',
border: '1px solid var(--border-subtle)',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s'
}}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
@@ -203,13 +250,33 @@ export default function Artists() {
{visible.map(artist => (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
style={selectionMode && selectedIds.has(artist.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)'
} : {}}
>
{selectionMode && (
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
@@ -231,13 +298,27 @@ export default function Artists() {
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
id={`artist-${artist.id}`}
style={selectionMode && selectedIds.has(artist.id) ? {
background: 'var(--accent-dim)',
color: 'var(--accent)'
} : {}}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
@@ -255,10 +336,8 @@ export default function Artists() {
)}
{!loading && hasMore && (
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={loadMore}>
<ChevronDown size={16} /> {t('artists.loadMore')}
</button>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
File diff suppressed because it is too large Load Diff
+471 -74
View File
@@ -1,4 +1,4 @@
import React, { 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,22 +10,33 @@ 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';
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[]>([]);
@@ -34,14 +45,35 @@ 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');
const [ratings, setRatings] = useState<Record<string, number>>({});
const [showPlPicker, setShowPlPicker] = useState(false);
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const selectedIds = useSelectionStore(s => s.selectedIds);
const inSelectMode = selectedCount > 0;
const lastSelectedIdxRef = useRef<number | null>(null);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
@@ -67,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 {
@@ -80,6 +142,44 @@ export default function Favorites() {
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
// Clear selection when song list changes
useEffect(() => {
useSelectionStore.getState().clearAll();
lastSelectedIdxRef.current = null;
}, [songs]);
// Clear selection on click outside tracklist
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
useSelectionStore.getState().clearAll();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode]);
const toggleSelect = useCallback((id: string, idx: number, shift: boolean) => {
useSelectionStore.getState().setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, idx);
const to = Math.max(lastSelectedIdxRef.current, idx);
// we need visibleSongs here — read from latest closure via ref trick
// Instead, just toggle range based on idx into songs array
for (let j = from; j <= to; j++) {
const sid = songs[j]?.id;
if (sid) next.add(sid);
}
} else {
if (next.has(id)) { next.delete(id); }
else { next.add(id); lastSelectedIdxRef.current = idx; }
}
return next;
});
}, [songs]);
useEffect(() => {
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
@@ -105,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' }}>
@@ -112,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' }}>
@@ -148,72 +309,172 @@ 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>
</div>
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="track-num"><span className="track-num-number">#</span></div>;
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
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 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</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';
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
);
})}
{/* ── 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>
<div className="tracklist-col-picker" ref={pickerRef}>
)}
{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();
}}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</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>
@@ -230,22 +491,115 @@ export default function Favorites() {
</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) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') {
const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0;
return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id)));
}
}}
/>
<span className="track-num-number">#</span>
</div>
);
}
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,
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
style={{
display: 'flex',
width: '100%',
height: '100%',
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>
{visibleSongs.map((song, i) => {
const track = songToTrack(song);
const isSelected = selectedIds.has(song.id);
return (
<div
key={song.id}
className="track-row track-row-va"
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, visibleSongs.map(songToTrack));
if (e.ctrlKey || e.metaKey) {
toggleSelect(song.id, i, false);
} else if (inSelectMode) {
toggleSelect(song.id, i, e.shiftKey);
} else {
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;
@@ -255,7 +609,13 @@ export default function Favorites() {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
const { selectedIds: selIds } = useSelectionStore.getState();
if (selIds.has(song.id) && selIds.size > 1) {
const bulkTracks = visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack);
psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else {
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
@@ -267,6 +627,7 @@ export default function Favorites() {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{i + 1}</span>
@@ -278,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"
@@ -303,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>
)}
</>
+717 -47
View File
@@ -1,10 +1,20 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import {
getMusicFolders,
getMusicDirectory,
getMusicIndexes,
SubsonicDirectoryEntry,
SubsonicArtist,
SubsonicAlbum,
} from '../api/subsonic';
import { usePlayerStore, Track } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
import { useLocation } from 'react-router-dom';
// ── types ─────────────────────────────────────────────────────────────────────
type ColumnKind = 'roots' | 'indexes' | 'directory';
type NavPos = { colIndex: number; rowIndex: number };
let persistedPlayingPathIds: string[] = [];
type Column = {
id: string;
@@ -13,9 +23,28 @@ type Column = {
selectedId: string | null;
loading: boolean;
error: boolean;
kind: ColumnKind;
};
// ── helpers ───────────────────────────────────────────────────────────────────
/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */
function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null {
if (!item.isDir) return null;
const albumId = item.albumId ?? (item.album ? item.id : undefined);
if (!albumId) return null;
return {
id: albumId,
name: item.album ?? item.title,
artist: item.artist ?? '',
artistId: item.artistId ?? '',
coverArt: item.coverArt,
year: item.year,
genre: item.genre,
starred: item.starred,
userRating: item.userRating,
songCount: 0,
duration: 0,
};
}
function entryToTrack(e: SubsonicDirectoryEntry): Track {
return {
@@ -37,17 +66,69 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track {
};
}
// ── component ─────────────────────────────────────────────────────────────────
function isFolderBrowserArrowKey(e: React.KeyboardEvent): boolean {
return (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.code === 'ArrowUp' ||
e.code === 'ArrowDown' ||
e.code === 'ArrowLeft' ||
e.code === 'ArrowRight'
);
}
/** Modifiers from native event + getModifierState (WebKit/WebView can miss flags on the synthetic event). */
function folderBrowserHasKeyModifiers(e: React.KeyboardEvent): boolean {
const n = e.nativeEvent;
if (n.ctrlKey || n.altKey || n.shiftKey || n.metaKey) return true;
if (typeof n.getModifierState === 'function') {
return (
n.getModifierState('Control') ||
n.getModifierState('Alt') ||
n.getModifierState('Shift') ||
n.getModifierState('Meta') ||
n.getModifierState('OS')
);
}
return false;
}
export default function FolderBrowser() {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column[]>([]);
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
const wrapperRef = useRef<HTMLDivElement>(null);
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
const pendingNavColRef = useRef<number | null>(null);
const autoResolvedTrackRef = useRef<string | null>(null);
const prevTrackIdRef = useRef<string | null>(null);
const lastHotkeyRevealTsRef = useRef<number | null>(null);
const [keyboardPos, setKeyboardPos] = useState<NavPos | null>(null);
const [contextAnchorPos, setContextAnchorPos] = useState<NavPos | null>(null);
const [columnsViewportWidth, setColumnsViewportWidth] = useState(0);
const location = useLocation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
// ── root: load music folders on mount ─────────────────────────────────────
useEffect(() => {
const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false };
const placeholder: Column = {
id: 'root',
name: '',
items: [],
selectedId: null,
loading: true,
error: false,
kind: 'roots',
};
setColumns([placeholder]);
getMusicFolders()
.then(folders => {
@@ -63,28 +144,225 @@ export default function FolderBrowser() {
});
}, []);
// ── auto-scroll to newly added column ─────────────────────────────────────
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
requestAnimationFrame(() => {
el.scrollLeft = el.scrollWidth;
});
}, [columns.length]);
// ── click a directory ──────────────────────────────────────────────────────
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
setColumnsViewportWidth(el.clientWidth);
const observer = new ResizeObserver(() => {
setColumnsViewportWidth(el.clientWidth);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!wrapperRef.current) return;
requestAnimationFrame(() => {
columns.forEach((col, colIndex) => {
const selectedId = col.selectedId;
if (!selectedId) return;
const row = wrapperRef.current?.querySelector<HTMLElement>(
`.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-item-id="${selectedId}"]`,
);
row?.scrollIntoView({ block: 'nearest' });
});
if (keyboardPos) {
const kbdRow = wrapperRef.current?.querySelector<HTMLElement>(
`.folder-col[data-folder-col-index="${keyboardPos.colIndex}"] .folder-col-row[data-row-index="${keyboardPos.rowIndex}"]`,
);
kbdRow?.scrollIntoView({ block: 'nearest' });
}
const fallbackColIndex = [...columns]
.map((c, i) => (c.selectedId ? i : -1))
.filter(i => i >= 0)
.pop();
const baseColIndex = keyboardPos?.colIndex ?? fallbackColIndex ?? Math.max(0, columns.length - 1);
const focusColIndex = Math.min(Math.max(0, columns.length - 1), baseColIndex + 1);
const focusCol = wrapperRef.current?.querySelector<HTMLElement>(
`.folder-col[data-folder-col-index="${focusColIndex}"]`,
);
focusCol?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}, [columns, keyboardPos]);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const hasRows = columns.some(c => !c.loading && !c.error && c.items.length > 0);
if (!hasRows) return;
requestAnimationFrame(() => {
el.focus({ preventScroll: true });
});
}, [columns]);
useEffect(() => {
if (!keyboardNavActive) return;
const onMouseMove = () => setKeyboardNavActive(false);
window.addEventListener('mousemove', onMouseMove, { once: true });
return () => window.removeEventListener('mousemove', onMouseMove);
}, [keyboardNavActive]);
useEffect(() => {
setColumnFilters(prev => {
const next: Record<number, string> = {};
let changed = false;
Object.entries(prev).forEach(([k, v]) => {
const idx = Number(k);
if (idx < columns.length) next[idx] = v;
else changed = true;
});
return changed ? next : prev;
});
setFilterFocusCol(prev => (prev !== null && prev >= columns.length ? null : prev));
}, [columns.length]);
useEffect(() => {
if (!isContextMenuOpen) setContextAnchorPos(null);
}, [isContextMenuOpen]);
useEffect(() => {
if (!currentTrack?.id) {
setPlayingPathIds([]);
return;
}
setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : []));
}, [currentTrack?.id]);
useEffect(() => {
if (!isPlaying || !currentTrack?.id) return;
const selectedChain = columns
.map(c => c.selectedId)
.filter((id): id is string => !!id);
if (selectedChain.length === 0) return;
const lastSelectedId = selectedChain[selectedChain.length - 1];
const leafColumn = [...columns].reverse().find(c => c.selectedId);
const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId);
if (!leafItem || leafItem.isDir || leafItem.id !== currentTrack.id) return;
setPlayingPathIds(prev => {
if (
prev.length === selectedChain.length &&
prev.every((id, idx) => id === selectedChain[idx])
) {
return prev;
}
return selectedChain;
});
}, [columns, currentTrack?.id, isPlaying]);
useEffect(() => {
persistedPlayingPathIds = playingPathIds;
}, [playingPathIds]);
const filteredItemsByCol = useMemo(() => {
return columns.map((col, colIndex) => {
const query = (columnFilters[colIndex] ?? '').trim().toLowerCase();
if (!query) return col.items;
return col.items.filter(item => {
const haystack = `${item.title} ${item.artist ?? ''} ${item.album ?? ''}`.toLowerCase();
return haystack.includes(query);
});
});
}, [columns, columnFilters]);
const preferredRowIndex = useCallback((colIndex: number): number => {
const items = filteredItemsByCol[colIndex] ?? [];
if (items.length === 0) return -1;
const selectedId = columns[colIndex]?.selectedId;
if (selectedId) {
const selectedIdx = items.findIndex(it => it.id === selectedId);
if (selectedIdx >= 0) return selectedIdx;
}
return 0;
}, [filteredItemsByCol, columns]);
const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => {
for (let c = 0; c < cols.length; c++) {
const rowIndex = preferredRowIndex(c);
if (rowIndex >= 0) return { colIndex: c, rowIndex };
}
return null;
}, [preferredRowIndex]);
useEffect(() => {
if (pendingNavColRef.current !== null) {
const targetColIndex = pendingNavColRef.current;
const targetCol = columns[targetColIndex];
const targetItems = filteredItemsByCol[targetColIndex] ?? [];
if (targetCol && targetItems.length > 0 && !targetCol.loading && !targetCol.error) {
const rowIndex = preferredRowIndex(targetColIndex);
const safeRowIndex = Math.min(Math.max(0, rowIndex), targetItems.length - 1);
const targetItem = targetItems[safeRowIndex];
setColumns(prev =>
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)),
);
setKeyboardPos({
colIndex: targetColIndex,
rowIndex: safeRowIndex,
});
pendingNavColRef.current = null;
return;
}
}
setKeyboardPos(prev => {
if (!prev) return fallbackNavPos(columns);
if (prev.colIndex >= columns.length) return fallbackNavPos(columns);
const col = columns[prev.colIndex];
const visibleItems = filteredItemsByCol[prev.colIndex] ?? [];
if (col.loading || col.error || visibleItems.length === 0) return fallbackNavPos(columns);
if (prev.rowIndex >= visibleItems.length) {
return { colIndex: prev.colIndex, rowIndex: visibleItems.length - 1 };
}
return prev;
});
}, [columns, fallbackNavPos, preferredRowIndex, filteredItemsByCol]);
const clearFiltersRightOf = useCallback((colIndex: number) => {
setColumnFilters(prev => {
const next: Record<number, string> = {};
let changed = false;
Object.entries(prev).forEach(([k, v]) => {
const idx = Number(k);
if (idx <= colIndex) next[idx] = v;
else changed = true;
});
return changed ? next : prev;
});
setFilterFocusCol(prev => (prev !== null && prev > colIndex ? null : prev));
}, []);
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
// Mark selected + truncate columns after this one + add loading column
clearFiltersRightOf(colIndex);
const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory';
setColumns(prev => [
...prev.slice(0, colIndex + 1).map((c, i) =>
i === colIndex ? { ...c, selectedId: item.id } : c,
),
{ id: item.id, name: item.title, items: [], selectedId: null, loading: true, error: false },
{
id: item.id,
name: item.title,
items: [],
selectedId: null,
loading: true,
error: false,
kind: nextKind,
},
]);
// Column 0 holds music folder roots — their IDs are only valid for
// getIndexes.view (musicFolderId), not getMusicDirectory.view
const fetchItems = colIndex === 0
? getMusicIndexes(item.id)
: getMusicDirectory(item.id).then(d => d.child);
const fetchItems =
colIndex === 0 ? getMusicIndexes(item.id) : getMusicDirectory(item.id).then(d => d.child);
fetchItems
.then(items => {
@@ -105,26 +383,397 @@ export default function FolderBrowser() {
return next;
});
});
}, [clearFiltersRightOf]);
const handleFileClick = useCallback(
(colIndex: number, item: SubsonicDirectoryEntry) => {
setColumns(prev =>
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: item.id } : c)),
);
const path = [
...columns.slice(0, colIndex).map(c => c.selectedId).filter((id): id is string => !!id),
item.id,
];
setPlayingPathIds(path);
const visibleItems = filteredItemsByCol[colIndex] ?? columns[colIndex]?.items ?? [];
const queue = visibleItems.filter(it => !it.isDir).map(entryToTrack);
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
},
[columns, filteredItemsByCol, playTrack],
);
const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => {
setColumns(prev => {
const prevSelectedId = prev[colIndex]?.selectedId ?? null;
if (prevSelectedId !== itemId) {
clearFiltersRightOf(colIndex);
}
return prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c));
});
}, [clearFiltersRightOf]);
const clearSelectedInColumn = useCallback((colIndex: number) => {
setColumns(prev =>
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: null } : c)),
);
}, []);
// ── click a file (track) ───────────────────────────────────────────────────
const handleFileClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
setColumns(prev => prev.map((c, i) =>
i === colIndex ? { ...c, selectedId: item.id } : c,
));
// Build queue from all tracks in this column
const col = columns[colIndex];
const queue = col.items.filter(it => !it.isDir).map(entryToTrack);
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
}, [columns, playTrack]);
// ── render ─────────────────────────────────────────────────────────────────
const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
if (item.isDir) {
handleDirClick(colIndex, item);
pendingNavColRef.current = colIndex + 1;
return;
}
handleFileClick(colIndex, item);
}, [handleDirClick, handleFileClick]);
const openContextMenuForEntry = useCallback(
(col: Column, item: SubsonicDirectoryEntry, x: number, y: number) => {
if (item.isDir) {
if (col.kind === 'indexes') {
const artist: SubsonicArtist = { id: item.id, name: item.title, coverArt: item.coverArt };
openContextMenu(x, y, artist, 'artist');
return;
}
const album = entryToAlbumIfPresent(item);
if (album) {
openContextMenu(x, y, album, 'album');
return;
}
if (item.artistId) {
const artist: SubsonicArtist = {
id: item.artistId,
name: item.artist ?? item.title,
coverArt: item.coverArt,
};
openContextMenu(x, y, artist, 'artist');
return;
}
return;
}
openContextMenu(x, y, entryToTrack(item), 'song');
},
[openContextMenu],
);
const onColumnsKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (isContextMenuOpen) return;
const target = e.target as HTMLElement;
const inFilterInput =
target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true';
if (inFilterInput) return;
const key = e.key;
if (e.ctrlKey && e.code === 'KeyF') {
e.preventDefault();
const current = keyboardPos ?? fallbackNavPos(columns);
if (!current) return;
const colIndex = current.colIndex;
setFilterFocusCol(colIndex);
requestAnimationFrame(() => {
const input = filterInputRefs.current[colIndex];
if (!input) return;
input.focus();
input.select();
});
return;
}
if (isFolderBrowserArrowKey(e) && folderBrowserHasKeyModifiers(e)) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return;
setKeyboardNavActive(true);
const current = keyboardPos ?? fallbackNavPos(columns);
if (!current) return;
const col = columns[current.colIndex];
const visibleItems = filteredItemsByCol[current.colIndex] ?? [];
const item = visibleItems[current.rowIndex];
if (!col || !item) return;
e.preventDefault();
if (key === 'Enter' && e.ctrlKey) {
setContextAnchorPos(current);
const rowEl = wrapperRef.current?.querySelector<HTMLElement>(
`.folder-col-row[data-col-index="${current.colIndex}"][data-row-index="${current.rowIndex}"]`,
);
const rect = rowEl?.getBoundingClientRect();
const x = rect ? rect.left + 24 : 24;
const y = rect ? rect.top + rect.height / 2 : 24;
openContextMenuForEntry(col, item, x, y);
return;
}
if (key === 'ArrowUp') {
if (current.rowIndex > 0) {
const nextRowIndex = current.rowIndex - 1;
const nextItem = visibleItems[nextRowIndex];
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
else setSelectedInColumn(current.colIndex, nextItem.id);
} else if (
current.rowIndex === 0 &&
(filterFocusCol === current.colIndex || !!columnFilters[current.colIndex])
) {
setFilterFocusCol(current.colIndex);
requestAnimationFrame(() => {
const input = filterInputRefs.current[current.colIndex];
if (!input) return;
input.focus();
input.select();
});
}
return;
}
if (key === 'ArrowDown') {
if (current.rowIndex < visibleItems.length - 1) {
const nextRowIndex = current.rowIndex + 1;
const nextItem = visibleItems[nextRowIndex];
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
else setSelectedInColumn(current.colIndex, nextItem.id);
}
return;
}
if (key === 'ArrowLeft') {
if (current.colIndex > 0) {
clearSelectedInColumn(current.colIndex);
const nextColIndex = current.colIndex - 1;
clearFiltersRightOf(nextColIndex);
const rowIndex = preferredRowIndex(nextColIndex);
if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex });
}
return;
}
if (key === 'ArrowRight') {
const nextColIndex = current.colIndex + 1;
if (nextColIndex < columns.length) {
const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? [];
const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1);
if (rowIndex >= 0) {
const nextItem = nextVisibleItems[rowIndex];
setSelectedInColumn(nextColIndex, nextItem.id);
setKeyboardPos({ colIndex: nextColIndex, rowIndex });
return;
}
}
if (item.isDir) handleActivate(current.colIndex, item);
return;
}
if (key === 'Enter') {
if (e.shiftKey && !item.isDir) {
const toAppend = (filteredItemsByCol[current.colIndex] ?? [])
.filter(it => !it.isDir)
.map(entryToTrack);
if (toAppend.length > 0) enqueue(toAppend);
return;
}
handleActivate(current.colIndex, item);
}
}, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf]);
const onRowContextMenu = useCallback(
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
e.preventDefault();
e.stopPropagation();
setContextAnchorPos({ colIndex, rowIndex });
openContextMenuForEntry(col, item, e.clientX, e.clientY);
},
[openContextMenuForEntry],
);
const resolveColumnsForTrack = useCallback(async (
track: Track,
roots: SubsonicDirectoryEntry[],
): Promise<Column[] | null> => {
for (const root of roots) {
let indexes: SubsonicDirectoryEntry[];
try {
indexes = await getMusicIndexes(root.id);
} catch {
continue;
}
const artistEntry =
indexes.find(it => it.isDir && !!track.artistId && it.id === track.artistId) ??
indexes.find(it => it.isDir && it.title === track.artist);
if (!artistEntry) continue;
let artistChildren: SubsonicDirectoryEntry[];
try {
artistChildren = (await getMusicDirectory(artistEntry.id)).child;
} catch {
continue;
}
const albumEntry = artistChildren.find(it =>
it.isDir &&
(
(!!track.albumId && (it.albumId === track.albumId || it.id === track.albumId)) ||
(!!track.album && (it.album === track.album || it.title === track.album))
),
);
if (!albumEntry) continue;
let albumChildren: SubsonicDirectoryEntry[];
try {
albumChildren = (await getMusicDirectory(albumEntry.id)).child;
} catch {
continue;
}
const songEntry = albumChildren.find(it => !it.isDir && it.id === track.id);
if (!songEntry) continue;
return [
{ id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' },
{ id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' },
{ id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' },
{ id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' },
];
}
return null;
}, []);
const isSelectedPathForCurrentTrack =
isPlaying && currentTrack && playingPathIds[playingPathIds.length - 1] === currentTrack.id;
const activeColIndex = useMemo(() => {
if (keyboardPos) return keyboardPos.colIndex;
const fromSelection = [...columns]
.map((c, i) => (c.selectedId ? i : -1))
.filter(i => i >= 0);
if (fromSelection.length > 0) return fromSelection[fromSelection.length - 1];
return Math.max(0, columns.length - 1);
}, [columns, keyboardPos]);
const visibleAnchorColIndex = useMemo(
() => Math.min(Math.max(0, columns.length - 1), activeColIndex + 1),
[activeColIndex, columns.length],
);
const compactColumnsEnabled = useMemo(() => {
if (columns.length < 4 || columnsViewportWidth <= 0) return false;
const expandedColumnWidth = 220;
return columns.length * expandedColumnWidth > columnsViewportWidth;
}, [columns.length, columnsViewportWidth]);
const isColumnCompact = useCallback((col: Column, colIndex: number) => {
if (!compactColumnsEnabled) return false;
if (col.loading || col.error || col.items.length === 0) return false;
return Math.abs(colIndex - visibleAnchorColIndex) > 1;
}, [compactColumnsEnabled, visibleAnchorColIndex]);
useEffect(() => {
if (!currentTrack?.id) {
autoResolvedTrackRef.current = null;
return;
}
const hotkeyRevealTs = (location.state as { folderBrowserRevealTs?: number } | null)?.folderBrowserRevealTs ?? null;
const hotkeyRevealRequested = hotkeyRevealTs !== null && hotkeyRevealTs !== lastHotkeyRevealTsRef.current;
const forceReveal = hotkeyRevealRequested;
if (autoResolvedTrackRef.current === currentTrack.id && !forceReveal) return;
const rootCol = columns[0];
if (!rootCol || rootCol.loading || rootCol.error || rootCol.items.length === 0) return;
const selectedLeafId =
[...columns].reverse().find(c => c.selectedId)?.selectedId ?? null;
const wasOnPreviousTrackPath = !!prevTrackIdRef.current && selectedLeafId === prevTrackIdRef.current;
if (selectedLeafId === currentTrack.id) {
autoResolvedTrackRef.current = currentTrack.id;
if (hotkeyRevealRequested) {
lastHotkeyRevealTsRef.current = hotkeyRevealTs;
}
return;
}
if (!forceReveal && !wasOnPreviousTrackPath) return;
let cancelled = false;
resolveColumnsForTrack(currentTrack, rootCol.items).then((resolved) => {
if (cancelled || !resolved) return;
setColumns(resolved);
const path = resolved.map(c => c.selectedId).filter((id): id is string => !!id);
setPlayingPathIds(path);
const leafColIndex = resolved.length - 1;
const leafRowIndex = resolved[leafColIndex].items.findIndex(it => it.id === currentTrack.id);
if (leafRowIndex >= 0) setKeyboardPos({ colIndex: leafColIndex, rowIndex: leafRowIndex });
autoResolvedTrackRef.current = currentTrack.id;
if (hotkeyRevealRequested) {
lastHotkeyRevealTsRef.current = hotkeyRevealTs;
}
});
return () => { cancelled = true; };
}, [columns, currentTrack, resolveColumnsForTrack, location.state]);
useEffect(() => {
prevTrackIdRef.current = currentTrack?.id ?? null;
}, [currentTrack?.id]);
return (
<div className="folder-browser">
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
<div className="folder-browser-columns" ref={wrapperRef}>
<div
className={`folder-browser-columns${keyboardNavActive ? ' keyboard-nav-active' : ''}${compactColumnsEnabled ? ' folder-browser-columns--compact' : ''}`}
ref={wrapperRef}
tabIndex={0}
onKeyDown={onColumnsKeyDown}
>
{columns.map((col, colIndex) => (
<div key={`${col.id}-${colIndex}`} className="folder-col">
<div
key={`${col.id}-${colIndex}`}
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
data-folder-col-index={colIndex}
>
{(filterFocusCol === colIndex || !!columnFilters[colIndex]) && (
<div className="folder-col-filter">
<input
ref={el => { filterInputRefs.current[colIndex] = el; }}
data-folder-filter-input="true"
className="folder-col-filter-input"
value={columnFilters[colIndex] ?? ''}
placeholder={t('playlists.searchPlaceholder')}
onFocus={() => setFilterFocusCol(colIndex)}
onBlur={() => {
if (!(columnFilters[colIndex] ?? '').trim()) {
setFilterFocusCol(prev => (prev === colIndex ? null : prev));
}
}}
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setColumnFilters(prev => ({ ...prev, [colIndex]: '' }));
setFilterFocusCol(null);
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
return;
}
if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) {
e.preventDefault();
e.stopPropagation();
const rowIndex = preferredRowIndex(colIndex);
if (rowIndex >= 0) {
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
if (nextItem) {
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
else setSelectedInColumn(colIndex, nextItem.id);
}
setKeyboardPos({ colIndex, rowIndex });
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
}
}
}}
onChange={e => {
const value = e.target.value;
setColumnFilters(prev => ({ ...prev, [colIndex]: value }));
setKeyboardPos(prev => {
if (!prev || prev.colIndex !== colIndex) return prev;
return { colIndex, rowIndex: 0 };
});
}}
/>
</div>
)}
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
@@ -133,32 +782,53 @@ export default function FolderBrowser() {
<div className="folder-col-status folder-col-error">
{t('folderBrowser.error')}
</div>
) : col.items.length === 0 ? (
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
) : (
col.items.map(item => {
(filteredItemsByCol[colIndex] ?? []).map((item, rowIndex) => {
const isSelected = col.selectedId === item.id;
const isContextRow =
contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex;
const isKeyboardRow =
keyboardPos?.colIndex === colIndex && keyboardPos?.rowIndex === rowIndex;
const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id;
const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id));
return (
<button
key={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}`}
onClick={() =>
item.isDir
? handleDirClick(colIndex, item)
: handleFileClick(colIndex, item)
}
type="button"
title={item.title}
data-col-index={colIndex}
data-row-index={rowIndex}
data-item-id={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
onClick={() => {
setKeyboardPos({ colIndex, rowIndex });
if (item.isDir) handleDirClick(colIndex, item);
else handleFileClick(colIndex, item);
}}
onKeyDown={e => {
if (!isFolderBrowserArrowKey(e) || folderBrowserHasKeyModifiers(e)) return;
e.preventDefault();
}}
onContextMenu={e => {
setKeyboardPos({ colIndex, rowIndex });
onRowContextMenu(e, colIndex, rowIndex, col, item);
}}
>
<span className="folder-col-icon">
{item.isDir
? isSelected
? <FolderOpen size={14} />
: <Folder size={14} />
: <Music size={14} />}
<span className={`folder-col-icon${isPathPlayingIcon ? ' folder-col-path-playing-icon' : ''}`}>
{item.isDir ? (
isSelected ? (
<FolderOpen size={14} />
) : (
<Folder size={14} />
)
) : (
<Music size={14} strokeWidth={isNowPlayingTrack ? 2.5 : 2} className={isNowPlayingTrack && isPlaying ? 'folder-col-playing-icon' : undefined} />
)}
</span>
<span className="folder-col-name">{item.title}</span>
{item.isDir && (
<ChevronRight size={12} className="folder-col-chevron" />
)}
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
</button>
);
})
+31 -7
View File
@@ -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'),
+1 -1
View File
@@ -120,7 +120,7 @@ export default function Home() {
{isVisible('discover') && (
<AlbumRow
title={t('home.discover')}
titleLink="/random-albums"
titleLink="/random/albums"
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
+4 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
@@ -50,6 +51,7 @@ export default function NewReleases() {
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -183,6 +185,7 @@ export default function NewReleases() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
+4 -4
View File
@@ -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>
+1166 -172
View File
File diff suppressed because it is too large Load Diff
+165 -52
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
import { getPlaylists, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { showToast } from '../utils/toast';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -16,10 +17,13 @@ export default function Playlists() {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
const playlists = usePlaylistStore((s) => s.playlists);
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
@@ -27,24 +31,45 @@ export default function Playlists() {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false));
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
useEffect(() => {
fetchPlaylists().finally(() => setLoading(false));
}, [fetchPlaylists]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
}, [creating]);
const createPlaylist = usePlaylistStore(s => s.createPlaylist);
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
await createPlaylist(name);
const updated = await getPlaylists();
setPlaylists(updated);
} catch {}
await createPlaylist(name);
// Refresh playlists from API to get the new one
await fetchPlaylists();
setCreating(false);
setNewName('');
};
@@ -73,11 +98,67 @@ export default function Playlists() {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
setPlaylists((prev) => prev.filter((p) => p.id !== pl.id));
} catch {}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== pl.id),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
setDeleteConfirmId(null);
};
const handleDeleteSelected = async () => {
if (selectedPlaylists.length === 0) return;
let deleted = 0;
for (const pl of selectedPlaylists) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !selectedIds.has(p.id)),
}));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
}
};
const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => {
if (selectedPlaylists.length === 0) return;
try {
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
const targetIds = new Set(targetSongs.map(s => s.id));
let totalAdded = 0;
for (const pl of selectedPlaylists) {
if (pl.id === targetPlaylist.id) continue;
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter(s => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach(s => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
touchPlaylist(targetPlaylist.id);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
clearSelection();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
}
};
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -91,34 +172,51 @@ export default function Playlists() {
{/* ── Header row ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
{!(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
</button>
</div>
</div>
@@ -130,10 +228,34 @@ export default function Playlists() {
{playlists.map((pl) => (
<div
key={pl.id}
className="album-card"
onClick={() => navigate(`/playlists/${pl.id}`)}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(pl.id);
} else {
navigate(`/playlists/${pl.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist');
} else {
openContextMenu(e.clientX, e.clientY, pl, 'playlist');
}
}}
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
style={selectionMode && selectedIds.has(pl.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)'
} : {}}
>
{selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{pl.coverArt ? (
@@ -163,15 +285,6 @@ export default function Playlists() {
</button>
</div>
{/* Delete button — top-right corner */}
<button
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
<div className="album-card-info">
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shuffle, Dices } from 'lucide-react';
interface MixCard {
icon: React.ElementType;
labelKey: string;
descKey: string;
to: string;
}
const CARDS: MixCard[] = [
{
icon: Shuffle,
labelKey: 'randomLanding.mixByTracks',
descKey: 'randomLanding.mixByTracksDesc',
to: '/random/mix',
},
{
icon: Dices,
labelKey: 'randomLanding.mixByAlbums',
descKey: 'randomLanding.mixByAlbumsDesc',
to: '/random/albums',
},
];
export default function RandomLanding() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="random-landing">
<div className="random-landing-grid">
{CARDS.map(({ icon: Icon, labelKey, descKey, to }) => (
<button
key={to}
className="mix-pick-card"
onClick={() => navigate(to)}
>
<Icon className="mix-pick-card-bg-icon" strokeWidth={1} aria-hidden />
<div className="mix-pick-card-content">
<Icon size={28} strokeWidth={1.5} className="mix-pick-card-icon" aria-hidden />
<span className="mix-pick-card-label">{t(labelKey)}</span>
<span className="mix-pick-card-desc">{t(descKey)}</span>
</div>
</button>
))}
</div>
</div>
);
}
+19 -2
View File
@@ -182,8 +182,17 @@ export default function RandomMix() {
<h1 className="page-title">{t('randomMix.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
<button
className="btn btn-surface"
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : fetchSongs}
disabled={selectedGenre ? genreMixLoading : loading}
data-tooltip={selectedGenre
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
: t('randomMix.remixTooltip')
}
>
<RefreshCw size={18} className={(selectedGenre ? genreMixLoading : loading) ? 'spin' : ''} />
{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
</button>
{(() => {
const isGenreLoading = selectedGenre && !genreMixComplete;
@@ -313,6 +322,14 @@ export default function RandomMix() {
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
) : (
<>
<button
className={`btn ${selectedGenre === null ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => { setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }}
disabled={genreMixLoading}
>
{t('randomMix.genreMixAll')}
</button>
{displayedGenres.map(genre => (
<button
key={genre}
+27 -5
View File
@@ -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)}
+745 -134
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -161,7 +161,7 @@ export default function Statistics() {
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
const stats = [
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—', tooltip: t('statistics.statArtistsTooltip') },
{ label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) },
{ label: t('statistics.statSongs'), value: countDisplay(totalSongs) },
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
@@ -183,7 +183,7 @@ export default function Statistics() {
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value}</span>
<span className="stats-card-label">{s.label}</span>
<span className="stats-card-label" data-tooltip={s.tooltip} data-tooltip-wrap="true">{s.label}</span>
</div>
))}
</div>
+84 -1
View File
@@ -19,6 +19,15 @@ export interface ServerProfile {
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
{ id: 'server', enabled: true },
{ id: 'lrclib', enabled: true },
{ id: 'netease', enabled: false },
];
interface AuthState {
// Multi-server
servers: ServerProfile[];
@@ -39,6 +48,8 @@ interface AuthState {
customGenreBlacklist: string[];
replayGainEnabled: boolean;
replayGainMode: 'track' | 'album';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
@@ -50,11 +61,18 @@ interface AuthState {
minimizeToTray: boolean;
discordRichPresence: boolean;
enableAppleMusicCoversDiscord: boolean;
discordTemplateDetails: string;
discordTemplateState: string;
discordTemplateLargeText: string;
useCustomTitlebar: boolean;
nowPlayingEnabled: boolean;
lyricsServerFirst: boolean;
enableNeteaselyrics: boolean;
lyricsSources: LyricsSourceConfig[];
showFullscreenLyrics: boolean;
showFsArtistPortrait: boolean;
/** Portrait dimming 0100 (percent), applied as CSS rgba alpha */
fsPortraitDim: number;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
@@ -62,6 +80,8 @@ interface AuthState {
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
/** Selected audio output device name. null = system default. */
audioOutputDevice: string | null;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
@@ -157,6 +177,8 @@ interface AuthState {
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setReplayGainMode: (v: 'track' | 'album') => void;
setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void;
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
@@ -168,15 +190,22 @@ 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;
setEnableNeteaselyrics: (v: boolean) => void;
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setShowFsArtistPortrait: (v: boolean) => void;
setFsPortraitDim: (v: number) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
@@ -189,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
@@ -244,6 +278,8 @@ export const useAuthStore = create<AuthState>()(
customGenreBlacklist: [],
replayGainEnabled: false,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
@@ -255,15 +291,22 @@ export const useAuthStore = create<AuthState>()(
minimizeToTray: false,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
discordTemplateDetails: '{artist} - {title}',
discordTemplateState: '{album}',
discordTemplateLargeText: '{album}',
useCustomTitlebar: false,
nowPlayingEnabled: false,
lyricsServerFirst: true,
enableNeteaselyrics: false,
lyricsSources: DEFAULT_LYRICS_SOURCES,
showFullscreenLyrics: true,
showFsArtistPortrait: true,
fsPortraitDim: 28,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
seekbarStyle: 'waveform',
enableHiRes: false,
audioOutputDevice: null,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
@@ -275,6 +318,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
randomNavMode: 'hub',
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
@@ -353,6 +397,14 @@ export const useAuthStore = create<AuthState>()(
set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainPreGainDb: (v) => {
set({ replayGainPreGainDb: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainFallbackDb: (v) => {
set({ replayGainFallbackDb: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
@@ -364,16 +416,23 @@ 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 }),
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
setLyricsSources: (sources) => set({ lyricsSources: sources }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
@@ -416,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;
@@ -505,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: () => {
@@ -522,6 +583,26 @@ export const useAuthStore = create<AuthState>()(
},
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
// If both hot cache and preload were enabled before mutual exclusion was enforced, reset both.
const conflictingLegacyState =
state.hotCacheEnabled && state.preloadMode !== 'off'
? { hotCacheEnabled: false, preloadMode: 'off' as const }
: {};
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {};
try {
const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record<string, unknown> };
if (!raw?.state?.lyricsSources) {
const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true;
const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false;
const migrated: LyricsSourceConfig[] = serverFirst
? [{ id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, { id: 'netease', enabled: neteaseOn }]
: [{ id: 'lrclib', enabled: true }, { id: 'server', enabled: true }, { id: 'netease', enabled: neteaseOn }];
lyricsSourcesMigrated = { lyricsSources: migrated };
}
} catch { /* ignore */ }
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
@@ -529,6 +610,8 @@ export const useAuthStore = create<AuthState>()(
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
...conflictingLegacyState,
...lyricsSourcesMigrated,
});
},
}
+40
View File
@@ -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' }),
}));
+105
View File
@@ -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 -1
View File
@@ -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;
+3 -14
View File
@@ -1,18 +1,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { formatKeyCode } from './keybindingsStore';
import { MODIFIER_KEY_CODES, formatBinding } from './keybindingsStore';
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
const MODIFIER_CODES = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
];
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
if (MODIFIER_CODES.includes(e.code)) return null;
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
@@ -27,13 +22,7 @@ export function buildGlobalShortcut(e: KeyboardEvent): string | null {
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
export function formatGlobalShortcut(shortcut: string): string {
return shortcut.split('+').map(part => {
if (part === 'ctrl') return 'Ctrl';
if (part === 'alt') return 'Alt';
if (part === 'shift') return 'Shift';
if (part === 'super' || part === 'meta') return 'Super';
return formatKeyCode(part);
}).join('+');
return formatBinding(shortcut);
}
// Module-level guard — prevents double-registration from React StrictMode's
+6 -3
View File
@@ -1,9 +1,11 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
import type { Track } from './playerStore';
const PREFETCH_AHEAD = 5;
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
export interface HotCacheEntry {
localPath: string;
@@ -22,7 +24,7 @@ interface HotCacheState {
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void;
totalBytes: () => number;
/** Evict until total size ≤ maxBytes. Respects queue tail first, protects current + next N. */
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */
evictToFit: (
queue: Track[],
queueIndex: number,
@@ -103,7 +105,7 @@ export const useHotCacheStore = create<HotCacheState>()(
if (maxBytes <= 0) return;
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + PREFETCH_AHEAD);
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].id);
@@ -127,6 +129,7 @@ export const useHotCacheStore = create<HotCacheState>()(
if (!parsed) continue;
const { serverId, trackId } = parsed;
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
if (isHotCachePreviousTrackUnderGrace(trackId, serverId)) continue;
const meta = entries[key];
const lru = lruStamp(meta);
+57 -4
View File
@@ -10,10 +10,17 @@ export type KeyAction =
| 'seek-forward'
| 'seek-backward'
| 'toggle-queue'
| 'open-folder-browser'
| 'fullscreen-player'
| 'native-fullscreen';
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
/** Physical keys only — ignore for binding capture */
export const MODIFIER_KEY_CODES = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
] as const;
// key = action, value = plain e.code ("Space", "KeyN") or chord "ctrl+shift+KeyN", null = unbound
export type Bindings = Record<KeyAction, string | null>;
export const DEFAULT_BINDINGS: Bindings = {
@@ -25,22 +32,56 @@ export const DEFAULT_BINDINGS: Bindings = {
'seek-forward': null,
'seek-backward': null,
'toggle-queue': null,
'open-folder-browser': null,
'fullscreen-player': null,
'native-fullscreen': 'F11',
};
interface KeybindingsState {
bindings: Bindings;
setBinding: (action: KeyAction, code: string | null) => void;
setBinding: (action: KeyAction, binding: string | null) => void;
resetToDefaults: () => void;
}
/** Build persisted binding from a keydown: single key or modifier+key chord. */
export function buildInAppBinding(e: KeyboardEvent): string | null {
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
if (!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) return e.code;
const mods: string[] = [];
if (e.ctrlKey) mods.push('ctrl');
if (e.altKey) mods.push('alt');
if (e.shiftKey) mods.push('shift');
if (e.metaKey) mods.push('super');
return [...mods, e.code].join('+');
}
/** True if the event matches a stored binding (legacy plain codes = no modifiers). */
export function matchInAppBinding(e: KeyboardEvent, binding: string | null): boolean {
if (!binding) return false;
if (!binding.includes('+')) {
return (
e.code === binding &&
!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey
);
}
const parts = binding.split('+');
const code = parts[parts.length - 1];
if (e.code !== code) return false;
const mods = new Set(parts.slice(0, -1));
return (
e.ctrlKey === mods.has('ctrl') &&
e.altKey === mods.has('alt') &&
e.shiftKey === mods.has('shift') &&
e.metaKey === mods.has('super')
);
}
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set) => ({
bindings: { ...DEFAULT_BINDINGS },
setBinding: (action, code) =>
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
setBinding: (action, binding) =>
set(s => ({ bindings: { ...s.bindings, [action]: binding } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
}),
{ name: 'psysonic_keybindings' }
@@ -78,3 +119,15 @@ export function formatKeyCode(code: string): string {
if (code === 'Slash') return '/';
return code;
}
/** Label for settings UI: plain key or chord (same string shape as global shortcuts). */
export function formatBinding(binding: string): string {
if (!binding.includes('+')) return formatKeyCode(binding);
return binding.split('+').map(part => {
if (part === 'ctrl') return 'Ctrl';
if (part === 'alt') return 'Alt';
if (part === 'shift') return 'Shift';
if (part === 'super' || part === 'meta') return 'Super';
return formatKeyCode(part);
}).join('+');
}
+359 -46
View File
@@ -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';
@@ -32,6 +32,8 @@ export interface Track {
genre?: string;
samplingRate?: number;
bitDepth?: number;
/** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */
size?: number;
autoAdded?: boolean;
radioAdded?: boolean;
}
@@ -58,12 +60,66 @@ export function songToTrack(song: SubsonicSong): Track {
genre: song.genre,
samplingRate: song.samplingRate,
bitDepth: song.bitDepth,
size: song.size,
};
}
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));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/**
* Infinite queue source strategy (Instant Mix-like):
* 1) Prefer artist-driven candidates (Top + Similar) around the current track.
* 2) Fallback to random songs when artist-driven fetches are empty.
*/
async function buildInfiniteQueueCandidates(
seedTrack: Track | null,
existingIds: Set<string>,
count = 5,
): Promise<Track[]> {
const artistId = seedTrack?.artistId?.trim() || null;
const artistName = seedTrack?.artist?.trim() || null;
const [similar, top] = await Promise.all([
artistId ? getSimilarSongs2(artistId).catch(() => []) : Promise.resolve([]),
artistName ? getTopSongs(artistName).catch(() => []) : Promise.resolve([]),
]);
const seedId = seedTrack?.id ?? null;
const mixCandidates = shuffleArray(
[...top, ...similar]
.map(songToTrack)
.filter(t => t.id !== seedId && !existingIds.has(t.id)),
)
.slice(0, count)
.map(t => ({ ...t, autoAdded: true as const }));
if (mixCandidates.length > 0) return mixCandidates;
const random = await getRandomSongs(count, seedTrack?.genre).catch(() => []);
return random
.map(songToTrack)
.filter(t => t.id !== seedId && !existingIds.has(t.id))
.slice(0, count)
.map(t => ({ ...t, autoAdded: true as const }));
}
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;
@@ -82,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;
@@ -125,10 +183,12 @@ interface PlayerState {
x: number;
y: number;
item: any;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 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', queueIndex?: 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 };
@@ -160,11 +220,14 @@ 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.
let togglePlayLock = false;
/**
* Skip 1: counts in `authStore.skipStarManualSkipCountsByKey` (persisted).
* Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count;
@@ -272,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).
@@ -328,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'
@@ -351,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(() => {});
}
@@ -378,6 +503,8 @@ function handleAudioProgress(current_time: number, duration: number) {
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
hiResEnabled: authState.enableHiRes,
}).catch(() => {});
}
@@ -517,15 +644,32 @@ 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.
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
// Keep audio settings in sync whenever auth store changes.
const unsubAuth = useAuthStore.subscribe((state) => {
@@ -540,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;
@@ -561,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) {
@@ -576,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,
@@ -585,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 832 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;
@@ -609,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(() => {});
}
@@ -639,6 +843,7 @@ export function initAudioListeners(): () => void {
unsubDiscordPlayer();
unsubDiscordAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
radioMetaUnlisten.then(unlisten => unlisten());
};
}
@@ -649,6 +854,8 @@ export const usePlayerStore = create<PlayerState>()(
(set, get) => ({
currentTrack: null,
currentRadio: null,
currentPlaybackSource: null,
enginePreloadedTrackId: null,
queue: [],
queueIndex: 0,
isPlaying: false,
@@ -679,8 +886,8 @@ export const usePlayerStore = create<PlayerState>()(
repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex },
}),
closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false },
@@ -760,11 +967,19 @@ 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 ────────────────────────────────────────────────────────────
playRadio: (station) => {
playRadio: async (station) => {
const { volume } = get();
++playGeneration;
isAudioPaused = false;
@@ -774,9 +989,15 @@ export const usePlayerStore = create<PlayerState>()(
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
// to HTML5 <audio> — the browser cannot play playlist files directly.
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
.catch(() => station.streamUrl);
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
radioAudio.src = station.streamUrl;
radioAudio.volume = volume;
radioAudio.src = streamUrl;
const { replayGainFallbackDb } = useAuthStore.getState();
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
radioAudio.volume = Math.min(1, volume * fallbackFactor);
radioAudio.play().catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
@@ -785,6 +1006,7 @@ export const usePlayerStore = create<PlayerState>()(
set({
currentRadio: station,
currentTrack: null,
currentPlaybackSource: null,
queue: [],
queueIndex: 0,
isPlaying: true,
@@ -807,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.
@@ -818,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({
@@ -834,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;
@@ -849,18 +1106,27 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: track.duration,
replayGainDb,
replayGainPeak,
preGainDb: authState.replayGainPreGainDb,
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();
@@ -879,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) {
@@ -934,8 +1216,10 @@ export const usePlayerStore = create<PlayerState>()(
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
manual: false,
replayGainPeak: replayGainPeakCold,
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
@@ -966,6 +1250,8 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
}).catch((err: unknown) => {
@@ -1001,9 +1287,9 @@ export const usePlayerStore = create<PlayerState>()(
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
if (remainingAuto <= 2) {
infiniteQueueFetching = true;
getRandomSongs(5, currentTrack?.genre).then(songs => {
if (songs.length > 0) {
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
const existingIds = new Set(get().queue.map(t => t.id));
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
if (newTracks.length > 0) {
set(state => ({ queue: [...state.queue, ...newTracks] }));
}
}).catch(() => {}).finally(() => { infiniteQueueFetching = false; });
@@ -1089,15 +1375,15 @@ export const usePlayerStore = create<PlayerState>()(
if (infiniteQueueEnabled && repeatMode === 'off') {
if (infiniteQueueFetching) return;
infiniteQueueFetching = true;
getRandomSongs(5, currentTrack?.genre).then(songs => {
const existingIds = new Set(get().queue.map(t => t.id));
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
infiniteQueueFetching = false;
if (songs.length === 0) {
if (newTracks.length === 0) {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return;
}
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...newTracks];
get().playTrack(newTracks[0], newQueue, false);
@@ -1140,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);
},
@@ -1305,10 +1616,12 @@ export const usePlayerStore = create<PlayerState>()(
? (currentTrack.replayGainPeak ?? null)
: null;
invoke('audio_update_replay_gain', {
volume,
replayGainDb,
replayGainPeak
invoke('audio_update_replay_gain', {
volume,
replayGainDb,
replayGainPeak,
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
}).catch(console.error);
},
}),
+38 -1
View File
@@ -1,22 +1,59 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getPlaylists, createPlaylist as apiCreatePlaylist, SubsonicPlaylist } from '../api/subsonic';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
playlistsLoading: boolean;
lastModified: Record<string, number>;
touchPlaylist: (id: string) => void;
removeId: (id: string) => void;
fetchPlaylists: () => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<SubsonicPlaylist | null>;
addPlaylist: (playlist: SubsonicPlaylist) => void;
}
export const usePlaylistStore = create<PlaylistStore>()(
persist(
(set) => ({
(set, get) => ({
recentIds: [],
playlists: [],
playlistsLoading: false,
lastModified: {},
touchPlaylist: (id) =>
set((s) => ({
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
lastModified: { ...s.lastModified, [id]: Date.now() },
})),
removeId: (id) =>
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
fetchPlaylists: async () => {
set({ playlistsLoading: true });
try {
const playlists = await getPlaylists();
set({ playlists, playlistsLoading: false });
} catch {
set({ playlistsLoading: false });
}
},
createPlaylist: async (name: string, songIds?: string[]) => {
try {
const playlist = await apiCreatePlaylist(name, songIds);
set((s) => ({
playlists: [...s.playlists, playlist],
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
}));
return playlist;
} catch {
return null;
}
},
addPlaylist: (playlist) => {
set((s) => ({
playlists: [...s.playlists, playlist],
}));
},
}),
{ name: 'psysonic_playlists_recent' }
)
+13
View File
@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface SelectionState {
selectedIds: Set<string>;
setSelectedIds: (update: (prev: Set<string>) => Set<string>) => void;
clearAll: () => void;
}
export const useSelectionStore = create<SelectionState>((set) => ({
selectedIds: new Set<string>(),
setSelectedIds: (update) => set((s) => ({ selectedIds: update(s.selectedIds) })),
clearAll: () => set({ selectedIds: new Set() }),
}));
+7 -5
View File
@@ -12,15 +12,17 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'mainstage', visible: true },
{ 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: 'randomMix', visible: true },
{ id: 'favorites', visible: true },
{ id: 'playlists', visible: true },
{ id: 'mostPlayed', visible: true },
{ id: 'radio', visible: true },
{ id: 'folderBrowser', visible: false },
{ id: 'deviceSync', visible: false },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
@@ -49,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;
},
}
)
+13 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula' | 'vision-dark' | 'vision-navy';
interface ThemeState {
theme: Theme;
@@ -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',
+1233 -9
View File
File diff suppressed because it is too large Load Diff
+166 -1
View File
@@ -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 {
@@ -2306,3 +2331,143 @@
color: inherit;
opacity: 0.7;
}
/* ─── Sidebar Expandable Playlists Section ─── */
.sidebar-playlists-wrapper {
display: flex;
flex-direction: column;
}
.sidebar-playlists-header-row {
display: flex;
align-items: center;
gap: var(--space-1);
padding-right: var(--space-2);
}
.sidebar-playlists-main-link {
flex: 1;
min-width: 0;
}
.sidebar-playlists-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border-radius: var(--radius-md);
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.sidebar-playlists-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlists-toggle.expanded {
transform: rotate(90deg);
color: var(--accent);
}
.sidebar-playlists-section {
display: flex;
flex-direction: column;
}
.sidebar-playlists-header {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
background: transparent;
border: none;
text-align: left;
}
.sidebar-playlists-header:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlists-header.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-playlists-chevron {
margin-left: auto;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.sidebar-playlists-chevron.expanded {
transform: rotate(90deg);
}
.sidebar-playlists-list {
display: flex;
flex-direction: column;
padding-left: var(--space-4);
padding-top: var(--space-1);
padding-bottom: var(--space-1);
gap: var(--space-1);
}
.sidebar-playlist-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 13px;
font-weight: 400;
transition: background var(--transition-fast), color var(--transition-fast);
overflow: hidden;
}
.sidebar-playlist-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.sidebar-playlist-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlist-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-playlists-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
}
.sidebar-playlists-empty {
padding: var(--space-2);
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
+723 -7
View File
@@ -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;
@@ -14317,3 +14389,647 @@ input[type="range"]:hover::-webkit-slider-thumb {
--warning: #ffb86c;
--danger: #ff5555;
}
/* ─── Vision Dark — Purple & Gold, colorblind-safe (Deuteranopia / Protanopia / Tritanopia) ─── */
/* Gold #FFD700 on #0D0B12 → contrast ~14.7:1 (WCAG AAA) */
[data-theme='vision-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%23C8C4D4%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");
--ctp-crust: #030210;
--ctp-mantle: #06050e;
--ctp-base: #0d0b12;
--ctp-surface0: #16131e;
--ctp-surface1: #201c2c;
--ctp-surface2: #2c2838;
--ctp-overlay0: #544e6a;
--ctp-overlay1: #6e6886;
--ctp-overlay2: #8a84a0;
--ctp-text: #f2eef8;
--ctp-subtext1: #c8c4d4;
--ctp-subtext0: #9c98aa;
--ctp-mauve: #8b5cf6; /* Purple — secondary accent */
--ctp-lavender: #a07af8;
--ctp-blue: #60a5fa;
--ctp-sapphire: #38bdf8;
--ctp-sky: #38bdf8;
--ctp-teal: #2dd4bf;
--ctp-green: #009e73; /* Okabe-Ito Bluish Green */
--ctp-yellow: #ffd700; /* Gold */
--ctp-peach: #e69f00;
--ctp-maroon: #d55e00; /* Okabe-Ito Vermillion — danger */
--ctp-red: #d55e00;
--ctp-flamingo: #f472b6;
--ctp-pink: #e879f9;
--ctp-rosewater: #f0abfc;
--bg-app: #0d0b12;
--bg-sidebar: #08060e;
--bg-card: #16131e;
--bg-hover: #201c2c;
--bg-player: #050310;
--bg-glass: rgba(13, 11, 18, 0.84);
--accent: #ffd700;
--accent-dim: rgba(255, 215, 0, 0.14);
--accent-glow: rgba(255, 215, 0, 0.36);
--text-primary: #f2eef8;
--text-secondary: #c8c4d4;
--text-muted: #7e7a8e;
--border: #201c2c;
--border-subtle: #16131e;
--positive: #009e73;
--warning: #e69f00;
--danger: #d55e00;
--volume-accent: #ffd700;
}
/* ─── Vision Navy — Purple & Gold, colorblind-safe (Deuteranopia / Protanopia / Tritanopia) ─── */
/* Gold #FFD700 on #0A1628 → contrast ~14.5:1 (WCAG AAA) */
[data-theme='vision-navy'] {
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%23C0C8D8%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");
--ctp-crust: #030a14;
--ctp-mantle: #060e1c;
--ctp-base: #0a1628;
--ctp-surface0: #112038;
--ctp-surface1: #1a2e48;
--ctp-surface2: #243c58;
--ctp-overlay0: #486480;
--ctp-overlay1: #607a98;
--ctp-overlay2: #7890ae;
--ctp-text: #eef2f8;
--ctp-subtext1: #c0c8d8;
--ctp-subtext0: #8898ae;
--ctp-mauve: #8b5cf6; /* Purple — secondary accent */
--ctp-lavender: #a07af8;
--ctp-blue: #3b82f6;
--ctp-sapphire: #38bdf8;
--ctp-sky: #7dd3fc;
--ctp-teal: #2dd4bf;
--ctp-green: #009e73; /* Okabe-Ito Bluish Green */
--ctp-yellow: #ffd700; /* Gold */
--ctp-peach: #e69f00;
--ctp-maroon: #d55e00; /* Okabe-Ito Vermillion */
--ctp-red: #d55e00;
--ctp-flamingo: #f472b6;
--ctp-pink: #e879f9;
--ctp-rosewater: #f0abfc;
--bg-app: #0a1628;
--bg-sidebar: #060e1c;
--bg-card: #112038;
--bg-hover: #1a2e48;
--bg-player: #040c18;
--bg-glass: rgba(10, 22, 40, 0.84);
--accent: #ffd700;
--accent-dim: rgba(255, 215, 0, 0.14);
--accent-glow: rgba(255, 215, 0, 0.36);
--text-primary: #eef2f8;
--text-secondary: #c0c8d8;
--text-muted: #607080;
--border: #1a2e48;
--border-subtle: #112038;
--positive: #009e73;
--warning: #e69f00;
--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;
}
+28
View File
@@ -1,6 +1,11 @@
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
let deferHotCachePrefetch = false;
/** Last track that was «current» before a forward queue step — not evicted until debounce elapses. */
let previousTrackGraceUntilMs = 0;
let previousTrackGraceTrackId: string | null = null;
let previousTrackGraceServerId: string | null = null;
export function setDeferHotCachePrefetch(v: boolean): void {
deferHotCachePrefetch = v;
}
@@ -8,3 +13,26 @@ export function setDeferHotCachePrefetch(v: boolean): void {
export function getDeferHotCachePrefetch(): boolean {
return deferHotCachePrefetch;
}
/** Call when `queueIndex` advances; the old current track stays eviction-safe for `debounceSec` (capped at 600 s). */
export function bumpHotCachePreviousTrackGrace(
trackId: string,
serverId: string,
debounceSec: number,
): void {
const sec = Number.isFinite(debounceSec) ? Math.min(600, Math.max(0, debounceSec)) : 0;
previousTrackGraceUntilMs = Date.now() + sec * 1000;
previousTrackGraceTrackId = trackId;
previousTrackGraceServerId = serverId;
}
export function isHotCachePreviousTrackUnderGrace(trackId: string, serverId: string): boolean {
if (!previousTrackGraceTrackId || Date.now() >= previousTrackGraceUntilMs) return false;
return previousTrackGraceTrackId === trackId && previousTrackGraceServerId === serverId;
}
export function clearHotCachePreviousGrace(): void {
previousTrackGraceUntilMs = 0;
previousTrackGraceTrackId = null;
previousTrackGraceServerId = null;
}
+25
View File
@@ -0,0 +1,25 @@
import { getAlbum, getArtist } from '../api/subsonic';
import { shuffleArray, songToTrack, usePlayerStore } from '../store/playerStore';
/**
* All tracks from the artists 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);
}
+35
View File
@@ -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');
}
}
+51
View File
@@ -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);
+30
View File
@@ -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
View File
@@ -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;
+19 -6
View File
@@ -21,25 +21,29 @@ function loadPrefs(
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[]; known?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).forEach(c => visible.add(c.key));
// Auto-show columns that are new since prefs were last saved.
// "known" tracks every column seen at save time; absent = newly added column → default to visible.
if (parsed.known) {
const known = new Set<string>(parsed.known);
columns.filter(c => !c.required && !known.has(c.key)).forEach(c => visible.add(c.key));
}
const widths = { ...defaultWidths, ...(parsed.widths ?? {}) };
const durationCol = columns.find(c => c.key === 'duration');
if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) {
widths.duration = defaultWidths.duration;
}
return {
widths,
visible,
};
return { widths, visible };
} catch {
return { widths: defaultWidths, visible: defaultVisible };
}
}
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] }));
const known = Object.keys(widths);
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible], known }));
}
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
@@ -160,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) => {
@@ -176,6 +188,7 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
gridStyle,
startResize,
toggleColumn,
resetColumns,
pickerOpen,
setPickerOpen,
pickerRef,