Compare commits

..

41 Commits

Author SHA1 Message Date
Psychotoxical dba0c26480 docs(changelog): add v1.34.5 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:04:03 +02:00
Psychotoxical 3643a78cd6 perf: drastically reduce background API requests to Navidrome (v1.34.5)
- NowPlaying polling: only active while dropdown is open + respects
  Page Visibility API — was firing every 10s unconditionally (~8.6k req/day)
- Connection check interval: 30s → 120s (4× reduction)
- Queue sync debounce: 1.5s → 5s (prevents burst on rapid skip)
- Rating prefetch: add 7-minute in-memory TTL cache for artist/album
  ratings — repeated random album loads no longer re-fetch known ratings

Fixes server log flooding reported by users behind reverse proxies (Traefik).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:04:03 +02:00
Psychotoxical 1e4b851e9e feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs
feat: disable native decorations on Linux, hide custom TitleBar for t…
2026-04-08 17:49:38 +02:00
kilyabin 37799fb861 feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs
On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, Openbox, etc.)
     the window has no title bar at all — neither native nor custom. On stacking DEs
     (GNOME, KDE, XFCE) the custom TitleBar can be toggled in settings as before.

     Native decorations are disabled on all Linux at startup. The frontend checks
     is_tiling_wm() to decide whether to render the custom TitleBar:
     - DE (GNOME/KDE): custom TitleBar shown (user-toggleable in settings)
     - Tiling WM: no TitleBar at all, setting hidden from Settings page

     Detection is based on environment variables:
     - HYPRLAND_INSTANCE_SIGNATURE, SWAYSOCK, I3SOCK (direct compositor signatures)
     - XDG_CURRENT_DESKTOP check for known tiling WMs

     Changes:
     - Added is_tiling_wm_cmd() Tauri command for frontend detection
     - Native decorations disabled on all Linux at startup
     - TitleBar not rendered on tiling WMs
     - Custom TitleBar setting hidden in Settings for tiling WMs
2026-04-08 19:36:39 +04:00
Psychotoxical 099516121e perf(fullscreen): fix dynamic accent color delay via direct fetch + cache
- Bypass ImageCache 5-slot queue: fetch cover art directly, create blob URL
  for canvas extraction, revoke immediately after use
- Module-level cache (artKey → accent) avoids re-fetching within the same
  album — same-cover tracks get the color instantly
- Keep previous color visible on cache miss instead of flashing to theme color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:13:48 +02:00
Psychotoxical 9047a44480 perf(fullscreen): extract dynamic accent from already-loaded cover blob
Previously a separate getCachedUrl call fetched the 300px cover art just
for color extraction, racing with the 500px fetch for display. Now reuses
resolvedCoverUrl directly — color appears as soon as the display image is
ready, with no redundant network request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:56:18 +02:00
Psychotoxical 47fcade3b3 feat(settings): show hint in theme picker when scheduler is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:51:43 +02:00
Psychotoxical d832dfb253 fix(statistics): remove Top Rated Songs/Artists sections
The implementation was unreliable (depended on starred songs + session-only
overrides) so the sections are removed entirely. All PR#130 rating logic
(StarRating, setRating API calls, userRatingOverrides) is untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:37:12 +02:00
Psychotoxical 041d946b58 feat(settings): 12-hour AM/PM time format for English locale in theme scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:23:46 +02:00
Psychotoxical 64d8b1cbd3 fix(titlebar): window drag, resize grips and mobile-mode grid for custom titlebar
- Add core:window:allow-start-dragging capability so data-tauri-drag-region
  actually works (was silently blocked without this permission)
- Add CSS resize grips (bottom-left + bottom-right) that replace the native
  GTK grip lost when decorations: false
- Add [data-mobile][data-titlebar] grid override so the titlebar renders
  correctly in narrow-window mode instead of being hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:13:58 +02:00
Psychotoxical 5848c621fd feat(folder-browser): Miller columns directory navigation
New page /folders with macOS Finder-style column layout:
- getMusicDirectory / getMusicIndexes / getMusicFolders Subsonic API methods
- Root level uses getIndexes.view (music folder IDs are not getMusicDirectory IDs)
- Columns fill available width with flex: 1, min-width: 200px
- Auto-scroll to rightmost column on expand
- Cancelled-flag prevents stale state on fast navigation
- Clicking a track plays it in the context of the current column's tracks
- FolderOpen/Folder/Music icons, accent-colored selection, ellipsis truncation
- Hidden in sidebar by default (user can enable in Settings)
- i18n: EN DE FR NL NB ZH RU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:40:29 +02:00
Psychotoxical 0a0dde057d feat(seekbar): fade edges on waveform style
Apply a destination-in gradient mask after drawing the waveform bars so
both the left and right edges fade smoothly to transparent instead of
cutting off abruptly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:30:01 +02:00
Psychotoxical 0119e27f6d feat(covers): logo fallback for broken cover art images
CachedImage now shows /logo-psysonic.png when an image fails to load
instead of a broken icon. The fallback switches to object-fit: contain
with a card background so the logo stays proportional inside the container.

If the caller provides a custom onError (e.g. to hide the element), that
handler is used instead. A separate fallbackSrc state prevents React from
re-writing the broken URL on re-renders. The DOM-level onerror = null
guard prevents any infinite error loop.

extractCoverColors skips color extraction when the logo fallback is
active, falling back to the theme accent color instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:25 +02:00
Psychotoxical c7adb599ee fix(artist): decouple artist info fetch from main render path (fix #132)
getArtistInfo2.view can trigger Navidrome to do slow external lookups
(Last.fm / Apple Music) when no local artist image exists, blocking the
entire page for 10+ seconds.

Render the page immediately after getArtist() resolves (local data only),
then fire getArtistInfo and getTopSongs in the background. A cancelled
flag prevents stale state updates when the user navigates away before
either background fetch completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:10:01 +02:00
Psychotoxical d6546e12ca docs(readme): bring features and roadmap up to date with v1.34.4
- Update language count to 7 (add Norwegian and Russian)
- Update theme count to 67, add Theme Scheduler mention
- Expand seekbar description to all 10 styles
- Add missing features: Ratings, Fullscreen Player, Playlist Management,
  AutoEQ, Internet Radio, Tray Icon, Backup & Restore, UI Scale,
  Album Multiselect, Custom Linux Titlebar
- Add ~15 missing completed items to roadmap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:57:25 +02:00
Psychotoxical f0438cae5e fix(seekbar): pulsewave clean connection, retrotape rolling wheel at playhead
Pulsewave: draw flat played line only up to where the Gaussian envelope
starts (startX), eliminating overlap between the flat line and the wave.

Retrotape: replace dual fixed-position reels with a single spinning reel
that tracks the playhead position across the full seek bar width.

fix(i18n): remove literal 'N' from skip-star descriptions in all languages

Replace 'After N skips' with natural phrasing ('after several skips') in
EN, FR, NL, NB, ZH. Simplify ratingsSkipStarThresholdLabel to a single
word (Skips / Sauts / Overslagen / Hopp / 跳过次数). DE was already fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:46:58 +02:00
Psychotoxical e64d151079 feat(settings): theme scheduler, UI scale, CustomSelect scroll fix
- Time-based theme scheduler: auto-switches between day/night theme
  based on configurable times; setInterval re-checks every minute
- UI scale slider (80–150%) via CSS zoom on document root; preset
  buttons aligned to actual slider positions
- CustomSelect: scroll listener keeps dropdown anchored on scroll
- All features i18n in 7 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:23:24 +02:00
Psychotoxical f0b423ddc4 Update CHANGELOG.md 2026-04-08 12:04:37 +02:00
Psychotoxical 8f3b9ebe00 ci: use setup-node built-in npm cache instead of manual cache step 2026-04-08 11:30:05 +02:00
Psychotoxical d4b18fec5a fix(statistics): explicit Map<string, SubsonicSong> type to satisfy tsc 2026-04-08 11:17:05 +02:00
Psychotoxical dbb53bfa70 release: bump to v1.34.4
Song ratings in context menu + player bar, entity ratings (PR #130),
5 new seekbar styles, custom Linux title bar, album multi-select,
mix rating filter, top-rated stats, compilation filter, scroll reset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:13:23 +02:00
Psychotoxical 737b057d4a feat(seekbar+ratings): 5 new seekbar styles and entity/mix rating system (PR #130)
Seekbar styles (5 new, by Psychotoxical):
- Neon Glow: transparent track, played section as multi-layer glowing neon tube
- Pulse Wave: flat line with animated gaussian sine pulse at playhead (rAF)
- Particle Trail: particles spawn at playhead and drift with glow (rAF)
- Liquid Fill: glass tube fills with liquid and animated wave surface (rAF)
- Retro Tape: two spinning reels connected by tape, shrink/grow with progress (rAF)

Ratings system (PR #130 by cucadmuh):
- Shared StarRating component with pulse/clear animations, disabled/locked states
- Entity ratings for albums and artists via OpenSubsonic probe (setEntityRatingSupport)
- Skip→1★: after N consecutive manual skips, set unrated track to 1★ (persisted per server)
- Mix rating filter: exclude low-rated songs/albums/artists from RandomMix, RandomAlbums, Hero, Home
- Batch refill in fetchRandomMixSongsUntilFull to fill list despite strict filters
- Prefetch artist/album ratings via getArtist/getAlbum for incomplete list payloads
- Settings: new Ratings section for skip threshold and per-axis mix filter stars
- i18n: all 7 languages (en, de, fr, nl, zh, nb, ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:29:21 +02:00
Maxim Isaev 0c2ac13fed merge: integrate origin/main into feat/improve-rating-ux
Resolve conflicts in RandomAlbums (keep mix rating filter with batch ZIP/offline
selection from upstream) and Settings (SeekbarPreview + mix filter constants).
2026-04-08 05:04:12 +03:00
Maxim Isaev 1624880c2a feat(ratings): mix cutoff filter for random flows and home, i18n
Apply the same per-axis star cutoff to random mix (multi-batch fetch until
full), random albums, Hero, and Home hero/discover rows. Prefetch artist and
album ratings via Subsonic when list payloads omit them. Exclude items when
0 < rating ≤ threshold; 0 or missing counts as unrated.

Settings: rating filter description interpolates sidebar menu labels; Russian
strings for custom title bar; short axis labels aligned across locales.
2026-04-08 04:57:26 +03:00
Psychotoxical 6587c82e0c fix(backup): include psysonic_home in backup keys
Home section visibility/order is a user preference and was missing
from the backup. Server-specific stores (offline, playlists_recent)
intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:40:54 +02:00
Maxim Isaev 8a1d942128 feat(ratings): skip-to-1★ threshold and mix min-rating filter
Add Ratings section under General: manual skip count before setting 1★,
and per-axis minimum stars for random mixes/albums (song, album, artist).
StarRating shows five stars with selection capped at three; shared max in authStore.
Queue filtering via passesMixMinRatings; OpenSubsonic-style entity rating fields on types.
2026-04-08 02:39:25 +03:00
Psychotoxical 829936a78d feat(seekbar): configurable seekbar styles with animated preview
Add five seekbar styles selectable in Settings → Appearance:
Waveform, Line & Dot, Bar, Thick Bar, and Segmented. Each option
shows an animated canvas preview using requestAnimationFrame.

Style is persisted in authStore (seekbarStyle, default: waveform).
Translations added for all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:39:08 +02:00
Psychotoxical ada7dd010f Revert "feat(waveform): real amplitude waveform via Symphonia + smooth crossfade"
This reverts commit 504c53e71d.
2026-04-08 01:23:36 +02:00
Psychotoxical 67de94d955 chore: update screenshots and fix titlebar window capabilities
- Replace single screenshot with 4 new screenshots in README
- Remove old icon.png, logo.png, screenshot.png
- Add missing allow-minimize + allow-toggle-maximize capabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:06:52 +02:00
Maxim Isaev 705c80ef07 feat(ratings): Subsonic entity ratings and StarRating UX
Probe OpenSubsonic after connect; persist album/artist ratings via setRating when supported. Add shared StarRating (repeat same star to clear, pulse and shrink animations, hover freeze after clear). Widen tracklist duration column and bump narrow saved widths in localStorage. Use StarRating in AlbumHeader, track lists, and playlist rows.
2026-04-08 01:09:17 +03:00
Psychotoxical 504c53e71d feat(waveform): real amplitude waveform via Symphonia + smooth crossfade
Add Rust command `compute_waveform` that downloads the audio file,
seek-samples 500 evenly-spaced frames with Symphonia (fast path) and
computes peak amplitudes. Percentile-based normalisation (p5→p95)
preserves visible dynamics even for heavily compressed music.

Frontend caches results per track (module-level Map), shows the
pseudo-random fallback immediately and crossfades to the real waveform
over 400 ms (ease-in-out) once the computation finishes.

Also: Settings input-tab reset buttons now use btn-danger styling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:15 +02:00
Psychotoxical 5be7f7cabd feat(albums): add multi-select to Albums, New Releases, and Random Albums
Select multiple albums to add them to the offline cache or download
each as a separate ZIP. Action buttons appear inline in the topbar
when at least one album is selected; title shows selection count.
AlbumCard gains selectionMode/selected/onToggleSelect props with a
checkmark overlay and selected outline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:04:44 +02:00
Psychotoxical c1624342d3 fix: scroll content-body to top on route change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:22:51 +02:00
Psychotoxical 4182b67732 feat(most-played): add toggle to filter compilation artists
Toggle button in the Top Artists section header hides known compilation
artist names (Various Artists, VA, Soundtracks, OST, etc.) from the
ranking. Default off. Active state shown in accent color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:20:35 +02:00
Psychotoxical 7b4eeea0dc feat(titlebar): custom Linux title bar with now-playing display (#titlebar)
Replaces the native GTK/GNOME title bar with a built-in one that matches
the app theme on all Linux desktops. Tested on CachyOS + KDE Plasma and
Fedora + GNOME.

- set_decorations(false) on Linux at startup
- TitleBar component: drag region, minimize/maximize/close, centered now-playing
- Hides automatically in native fullscreen (F11) via onResized+isFullscreen
- Optional: toggle in Settings → Verhalten (default: on)
- macOS and Windows completely unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:08:44 +02:00
Psychotoxical 47cea7e3d6 fix(titlebar): detect fullscreen via onResized+isFullscreen instead of manual state
Manually tracking setIsWindowFullscreen was unreliable (e.g. WM-triggered
fullscreen bypasses the keybinding handler). Now listens to window resize
events and queries actual fullscreen state after each resize.

Tested on CachyOS + KDE Plasma and Fedora + GNOME.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:07:52 +02:00
Psychotoxical 5ed1b58d67 feat(titlebar): hide custom title bar in native fullscreen (F11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:05:31 +02:00
Psychotoxical 900853fedc fix(titlebar): re-focus window after re-enabling native decorations on GTK
GTK re-stacks the window when set_decorations(true) is called, causing it
to lose focus and sink behind other windows. Call set_focus() immediately
after to bring it back to the front.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:01:42 +02:00
Psychotoxical 2c3c89f078 fix(titlebar): add minimize and toggleMaximize window capabilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:50:49 +02:00
Psychotoxical 45fa606ae1 feat(titlebar): make custom title bar optional via Settings toggle
- New authStore field useCustomTitlebar (default: true)
- set_window_decorations Tauri command toggles native decorations at runtime
- Settings toggle visible only on Linux (no restart required)
- i18n keys added to EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:47:29 +02:00
Psychotoxical d49137475f feat(titlebar): custom Linux title bar with now-playing display
- set_decorations(false) on Linux at startup via #[cfg(target_os = "linux")]
- New TitleBar component: drag region + minimize/maximize/close buttons
- Currently playing track (▶/⏸ + artist – title) shown in the center
- app-shell grid gains a 32px titlebar row when data-titlebar is set
- IS_LINUX utility constant via navigator.platform
- macOS and Windows are completely unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:12:17 +02:00
73 changed files with 4174 additions and 347 deletions
+3 -19
View File
@@ -19,12 +19,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
cache: 'npm'
- name: get version
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
@@ -97,12 +92,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
@@ -141,13 +131,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
+78
View File
@@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.34.5] - 2026-04-08
### 🚨 Critical Fix
- **Massive API request flood fixed** *(closes [#133](https://github.com/Psychotoxical/psysonic/issues/133))*: Psysonic was generating 15,000+ background requests per day, filling reverse-proxy access logs (Traefik, nginx) and in some cases crashing the proxy entirely. Four root causes identified and resolved:
- **Now Playing polling**: Was firing every 10 seconds unconditionally — even when minimized or the dropdown was closed. Now only polls while the dropdown is open, and respects the Page Visibility API to pause immediately when the window is hidden.
- **Connection check interval**: Reduced from every 30 seconds to every **120 seconds** (4× reduction).
- **Queue sync debounce**: Increased from 1.5 s to **5 s**, preventing request bursts when skipping rapidly through tracks.
- **Rating prefetch cache**: Artist and album ratings are now cached in memory for **7 minutes**. Repeated page loads (Random Albums, Random Mix) no longer re-fetch ratings that were just retrieved.
### Added
- **Theme Scheduler** *(Settings → Appearance)*: Automatically switches the active theme at configurable times of day. Two time slots (e.g. a light theme during the day, a dark one at night). English locale displays hours in 12-hour AM/PM format; all other languages use 24-hour format.
- **Theme Scheduler hint**: When the scheduler is active, a notice appears at the top of the Theme Picker explaining why manually selecting a theme has no immediate effect.
- **UI Scale** *(Settings → Appearance)*: Adjust the global interface scale (80 % 125 %) without changing the system font size.
- **Folder Browser**: New sidebar section with Miller-columns directory navigation. Browse the server's music folder tree and play or queue folders directly.
- **Seekbar — Waveform fade edges**: The Waveform seekbar style now fades out at both ends, giving it a cleaner, less abrupt look.
- **Cover art fallback logo**: When a cover art image fails to load (broken URL, server error), the Psysonic logo is shown as a placeholder instead of a broken image icon.
- **Tiling WM support** *(PR [#134](https://github.com/Psychotoxical/psysonic/pull/134))*: On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, etc.) the custom title bar is automatically hidden — the WM manages window decorations. The title bar toggle in Settings is also hidden on tiling WMs. Detection is based on environment variables (`HYPRLAND_INSTANCE_SIGNATURE`, `SWAYSOCK`, `I3SOCK`, `XDG_CURRENT_DESKTOP`).
### Changed
- **Custom title bar disabled by default**: New installations start with the native OS title bar. Existing users keep their saved preference.
### Fixed
- **Custom title bar (Linux) — drag & resize**: Window dragging via the title bar now works correctly (missing Tauri `core:window:allow-start-dragging` capability was silently blocking it). CSS resize grips are now shown at the bottom corners to compensate for the removed native GTK grips. The title bar no longer misplaces itself when the window is resized to a small width (mobile-grid layout now includes the title bar row).
- **Fullscreen Player — accent color delay**: The dynamic accent color extracted from album artwork now appears in ~200300 ms instead of up to 18 seconds. The previous implementation queued the cover fetch behind up to 5 concurrent image loads via the app-wide image cache. It now fetches the cover directly and independently. The extracted color is also cached per cover ID, so switching between tracks on the same album is instant.
- **Artist Detail page — slow initial render** *(closes [#132](https://github.com/Psychotoxical/psysonic/issues/132))*: Artist info and biography are now fetched independently of the main artist data, so the page renders immediately and the bio fades in once available. Previously, a slow `getArtistInfo` response blocked the entire page from rendering.
- **Seekbar — Pulse Wave & Retro Tape styles**: Pulse Wave no longer leaves a stray connecting line at the playhead position. Retro Tape's rolling wheel is now anchored at the playhead instead of the center of the bar.
- **Statistics — Top Rated Songs/Artists sections removed**: These sections were incorrectly added in v1.34.4 and have been removed. All other rating features from that release remain fully intact.
---
## [1.34.4] - 2026-04-08
### Added
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (15 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating to a song after it has been manually skipped a configurable number of consecutive times. This skip count threshold can be enabled and adjusted in Settings → Ratings.
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
### Fixed
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
### i18n
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.3] - 2026-04-07
### Added
+33 -14
View File
@@ -28,27 +28,35 @@ Psysonic is a beautiful desktop music player built completely from the ground up
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
![Psysonic Screenshot](public/screenshot.png)
![Psysonic Screenshot](public/screenshot1.png)
## ✨ Features
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
- 🎨 **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, and Chinese.
- 🌍 **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**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll, line highlighting, click-to-seek, and plain-text fallback.
- 🎤 **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 and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
- 💿 **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**: 10 UI fonts to choose from in Settings → Appearance.
- 🔤 **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).
@@ -57,30 +65,41 @@ Designed specifically for users hosting their own music via Navidrome or other S
### ✅ 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] Waveform seekbar
- [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
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll, click-to-seek)
- [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] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
- [x] Internationalization (English, German, French, Dutch, Chinese)
- [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)
- [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 60+ themes
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 67 themes
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.3",
"version": "1.34.5",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.3
pkgver=1.34.4
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

+1 -1
View File
@@ -3339,7 +3339,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.34.3"
version = "1.34.5"
dependencies = [
"biquad",
"discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.3"
version = "1.34.5"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+3
View File
@@ -28,10 +28,13 @@
"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"
+1 -1
View File
@@ -1 +1 @@
{"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-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
{"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"]}}
+74
View File
@@ -36,6 +36,19 @@ fn exit_app(app_handle: tauri::AppHandle) {
app_handle.exit(0);
}
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
#[tauri::command]
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.set_decorations(enabled);
// Re-enabling native decorations on GTK causes the window manager to
// re-stack the window, which drops focus. Bring it back immediately.
if enabled {
let _ = win.set_focus();
}
}
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
@@ -803,6 +816,53 @@ fn stop_audio_engine(app: &tauri::AppHandle) {
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
/// set by the compositor / DE.
#[cfg(target_os = "linux")]
fn is_tiling_wm() -> bool {
// Direct compositor signatures (most reliable).
let direct = [
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
"SWAYSOCK", // Sway
"I3SOCK", // i3
]
.iter()
.any(|&var| std::env::var_os(var).is_some());
if direct {
return true;
}
// Check XDG_CURRENT_DESKTOP for known tiling WMs.
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = desktop.to_lowercase();
let tiling_wms = [
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
];
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
return true;
}
}
false
}
/// Tauri command: lets the frontend know whether we're running under a tiling
/// WM so it can decide whether to render the custom TitleBar component.
#[tauri::command]
fn is_tiling_wm_cmd() -> bool {
#[cfg(target_os = "linux")]
{
is_tiling_wm()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -826,6 +886,18 @@ pub fn run() {
}))
.setup(|app| {
// ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations on all Linux so the React TitleBar
// can take over. The frontend checks is_tiling_wm() to decide
// whether to actually render the TitleBar (hidden on tiling WMs).
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_decorations(false);
}
}
// ── System tray ───────────────────────────────────────────────
// Always build on startup; the frontend calls toggle_tray_icon(false)
// immediately after load if the user has disabled the tray icon.
@@ -951,6 +1023,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
greet,
exit_app,
set_window_decorations,
is_tiling_wm_cmd,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.3",
"version": "1.34.5",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+61 -8
View File
@@ -34,6 +34,7 @@ import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio';
import FolderBrowser from './pages/FolderBrowser';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
@@ -51,14 +52,17 @@ import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal';
import AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
import { IS_LINUX } from './utils/platform';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { getMusicFolders } from './api/subsonic';
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } 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';
@@ -73,6 +77,23 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
function AppShell() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
const [isTilingWm, setIsTilingWm] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
}, []);
useEffect(() => {
if (!IS_LINUX) return;
const win = getCurrentWindow();
let unlisten: (() => void) | undefined;
win.onResized(() => {
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
@@ -87,24 +108,47 @@ function AppShell() {
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Sync custom titlebar preference with native decorations on Linux
// On tiling WMs decorations are always off (no native title bar to replace).
useEffect(() => {
if (!IS_LINUX) return;
const enabled = isTilingWm ? false : !useCustomTitlebar;
invoke('set_window_decorations', { enabled }).catch(() => {});
}, [useCustomTitlebar, isTilingWm]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
const serverAtStart = activeServerId;
let cancelled = false;
(async () => {
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
try {
const folders = await getMusicFolders();
if (!cancelled) setMusicFolders(folders);
if (stillThisServer()) setMusicFolders(folders);
} catch {
if (!cancelled) setMusicFolders([]);
if (stillThisServer()) setMusicFolders([]);
}
try {
const level = await probeEntityRatingSupport();
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
} catch {
if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only');
}
})();
return () => {
cancelled = true;
};
}, [isLoggedIn, activeServerId, setMusicFolders]);
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
// Reset scroll position on route change
useEffect(() => {
document.querySelector('.content-body')?.scrollTo({ top: 0 });
}, [location.pathname]);
// Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus);
@@ -248,16 +292,18 @@ function AppShell() {
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
<div
className="app-shell"
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
style={{
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
@@ -315,6 +361,7 @@ function AppShell() {
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
<Route path="/folders" element={<FolderBrowser />} />
</Routes>
</div>
</main>
@@ -468,18 +515,24 @@ function TauriEventBridge() {
}
export default function App() {
const theme = useThemeStore(s => s.theme);
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
useEffect(() => {
document.documentElement.setAttribute('data-font', font);
}, [font]);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
useEffect(() => {
return initAudioListeners();
}, []);
+194
View File
@@ -64,6 +64,15 @@ export interface SubsonicAlbum {
starred?: string;
recordLabel?: string;
created?: string;
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
userRating?: number;
}
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
export interface SubsonicOpenArtistRef {
id?: string;
name?: string;
userRating?: number;
}
export interface SubsonicSong {
@@ -79,6 +88,11 @@ export interface SubsonicSong {
coverArt?: string;
year?: number;
userRating?: number;
/** Some OpenSubsonic responses attach parent ratings on child songs. */
albumUserRating?: number;
artistUserRating?: number;
artists?: SubsonicOpenArtistRef[];
albumArtists?: SubsonicOpenArtistRef[];
// Audio technical info
bitRate?: number;
suffix?: string;
@@ -141,6 +155,8 @@ export interface SubsonicArtist {
albumCount?: number;
coverArt?: string;
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
}
export interface SubsonicGenre {
@@ -165,6 +181,68 @@ export interface SubsonicArtistInfo {
}
// ─── API Methods ──────────────────────────────────────────────
export interface SubsonicDirectoryEntry {
id: string;
parent?: string;
title: string;
isDir: boolean;
album?: string;
artist?: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
track?: number;
year?: number;
bitRate?: number;
suffix?: string;
size?: number;
genre?: string;
starred?: string;
userRating?: number;
}
export interface SubsonicDirectory {
id: string;
parent?: string;
name: string;
child: SubsonicDirectoryEntry[];
}
export async function getMusicDirectory(id: string): Promise<SubsonicDirectory> {
const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
'getMusicDirectory.view',
{ id },
);
const dir = data.directory;
const raw = dir.child;
const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
return { id: dir.id, parent: dir.parent, name: dir.name, child };
}
/** Returns the top-level artist/directory entries for a music folder root.
* Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs
* use getIndexes.view with musicFolderId instead. */
export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
type IndexArtist = { id: string; name: string; coverArt?: string };
type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] };
const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>(
'getIndexes.view',
{ musicFolderId },
);
const raw = data.indexes?.index;
if (!raw) return [];
const indices = Array.isArray(raw) ? raw : [raw];
const entries: SubsonicDirectoryEntry[] = [];
for (const idx of indices) {
const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : [];
for (const a of artists) {
entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt });
}
}
return entries;
}
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
'getMusicFolders.view',
@@ -253,6 +331,100 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
return { album, songs: song ?? [] };
}
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
function getCachedRating(key: string): number | undefined | null {
const entry = ratingCache.get(key);
if (!entry) return null; // cache miss
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
return entry.value;
}
function setCachedRating(key: string, value: number | undefined): void {
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
}
function parseEntityUserRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return undefined;
return n;
}
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`artist:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
if (!uncached.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= uncached.length) return;
const id = uncached[i];
try {
const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating);
setCachedRating(`artist:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
export async function prefetchAlbumUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`album:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
if (!uncached.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= uncached.length) return;
const id = uncached[i];
try {
const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating);
setCachedRating(`album:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),
@@ -374,6 +546,28 @@ export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
}
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
export type EntityRatingSupportLevel = 'track_only' | 'full';
/**
* Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist
* rating as supported (same `setRating.view` + entity id); otherwise track-only.
*/
export async function probeEntityRatingSupport(): Promise<EntityRatingSupportLevel> {
try {
const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>(
'getOpenSubsonicExtensions.view',
{},
8000,
);
if (data.openSubsonic === true) return 'full';
if (Array.isArray(data.openSubsonicExtensions)) return 'full';
return 'track_only';
} catch {
return 'track_only';
}
}
export async function scrobbleSong(id: string, time: number): Promise<void> {
try {
await api('scrobble.view', { id, time, submission: true });
+40 -16
View File
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, HardDriveDownload } from 'lucide-react';
import { Play, HardDriveDownload, Check } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -11,9 +11,13 @@ import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
selected?: boolean;
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
showRating?: boolean;
}
function AlbumCard({ album }: AlbumCardProps) {
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -25,20 +29,26 @@ function AlbumCard({ album }: AlbumCardProps) {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
const handleClick = () => {
if (selectionMode) { onToggleSelect?.(album.id); return; }
navigate(`/album/${album.id}`);
};
return (
<div
className="album-card card"
onClick={() => navigate(`/album/${album.id}`)}
className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
if (selectionMode) { e.preventDefault(); return; }
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
onMouseDown={e => {
if (e.button !== 0) return;
if (selectionMode || e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
@@ -64,20 +74,27 @@ function AlbumCard({ album }: AlbumCardProps) {
</svg>
</div>
)}
{isOffline && (
{isOffline && !selectionMode && (
<div className="album-card-offline-badge" aria-label="Offline available">
<HardDriveDownload size={12} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
{selectionMode && (
<div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
{selected && <Check size={14} strokeWidth={3} />}
</div>
)}
{!selectionMode && (
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
)}
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
@@ -87,6 +104,13 @@ function AlbumCard({ album }: AlbumCardProps) {
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
{showRating && (album.userRating ?? 0) > 0 && (
<div className="album-card-rating-row">
<span className="album-card-rating-stars">
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
</span>
</div>
)}
</div>
</div>
);
+18
View File
@@ -6,6 +6,8 @@ import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -85,6 +87,10 @@ interface AlbumHeaderProps {
onEnqueueAll: () => void;
onBio: () => void;
onCloseBio: () => void;
entityRatingValue: number;
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
}
export default function AlbumHeader({
@@ -107,6 +113,9 @@ export default function AlbumHeader({
onEnqueueAll,
onBio,
onCloseBio,
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -186,6 +195,15 @@ export default function AlbumHeader({
</>
)}
</div>
<div className="album-detail-entity-rating">
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
{isMobile ? (
<div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */}
+3 -2
View File
@@ -12,9 +12,10 @@ interface Props {
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
showRating?: boolean;
}
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
@@ -89,7 +90,7 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
+20 -31
View File
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -24,29 +25,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
return parts.join(' ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const { t } = useTranslation();
const [hover, setHover] = React.useState(0);
return (
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
// ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
@@ -58,14 +36,14 @@ const COLUMNS: readonly ColDef[] = [
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
];
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where cell content should be centred (both header and rows)
// Columns where header label is centred in the cell (matches row controls below)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
@@ -76,6 +54,8 @@ interface AlbumTrackListProps {
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;
@@ -89,6 +69,7 @@ export default function AlbumTrackList({
currentTrack,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
onPlaySong,
onRate,
@@ -193,13 +174,21 @@ export default function AlbumTrackList({
);
}
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
// direction=1: drag right → this column grows, title (1fr) shrinks
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
const isResizable = !isLastCol;
return (
<div key={key} data-align={isCentered ? 'center' : 'start'} 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={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<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)} />
@@ -266,7 +255,7 @@ export default function AlbumTrackList({
return (
<StarRating
key="rating"
value={ratings[song.id] ?? song.userRating ?? 0}
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
);
+24 -3
View File
@@ -26,8 +26,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
}
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
const [inView, setInView] = useState(false);
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
@@ -49,14 +50,34 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
// URL upgrades within the same image — avoids the end-of-load flash.
useEffect(() => {
setLoaded(false);
setFallbackSrc(undefined);
}, [cacheKey]);
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
if (onError) {
// Caller wants custom error handling (e.g. hide the element)
onError(e);
} else {
// Nullify the DOM-level handler first to prevent any infinite loop
e.currentTarget.onerror = null;
setFallbackSrc('/logo-psysonic.png');
}
};
const isFallback = fallbackSrc !== undefined;
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
const fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
: {};
return (
<img
ref={imgRef}
src={resolvedSrc || undefined}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
src={finalSrc}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease', ...fallbackStyle }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
onError={handleError}
{...props}
/>
);
+17 -2
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -163,7 +164,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
@@ -381,6 +382,13 @@ export default function ContextMenu() {
);
})()}
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
@@ -501,6 +509,13 @@ export default function ContextMenu() {
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
+13 -3
View File
@@ -25,15 +25,14 @@ export default function CustomSelect({ value, options, onChange, className = '',
const selected = options.find(o => o.value === value);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const updateDropStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const maxH = 240;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
setDropStyle({
position: 'fixed',
left: rect.left,
@@ -44,6 +43,17 @@ export default function CustomSelect({ value, options, onChange, className = '',
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!open) return;
updateDropStyle();
}, [open]);
useEffect(() => {
if (!open) return;
window.addEventListener('scroll', updateDropStyle, true);
return () => window.removeEventListener('scroll', updateDropStyle, true);
}, [open]);
useEffect(() => {
+30 -9
View File
@@ -245,6 +245,10 @@ interface FullscreenPlayerProps {
onClose: () => void;
}
// Module-level cache: artKey → accent color string.
// Survives track changes so same-album songs reuse the extracted color instantly.
const coverAccentCache = new Map<string, string>();
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -291,18 +295,35 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
// Reset to null on track change so the previous color doesn't linger while
// the new one is being extracted.
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
// On cover change: hit cache for instant result, or fetch → extract → cache.
// Cache hit avoids re-fetching for same-album tracks. Reset only when uncached.
useEffect(() => {
setDynamicAccent(null);
if (!artUrl || !artKey) return;
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
const cached = coverAccentCache.get(artKey);
if (cached) { setDynamicAccent(cached); return; }
// No cache hit — keep the previous color visible until extraction completes.
let cancelled = false;
getCachedUrl(artUrl, artKey).then(blobUrl => {
if (cancelled || !blobUrl) return;
extractCoverColors(blobUrl).then(colors => {
if (!cancelled && colors.accent) setDynamicAccent(colors.accent);
});
});
let blobUrl = '';
(async () => {
try {
const resp = await fetch(artUrl);
if (cancelled) return;
const blob = await resp.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
const colors = await extractCoverColors(blobUrl);
if (cancelled) return;
if (colors.accent) {
coverAccentCache.set(artKey, colors.accent);
setDynamicAccent(colors.accent);
}
} catch { /* ignore */ } finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
}
})();
return () => { cancelled = true; };
}, [artKey]); // artKey is stable per track — artUrl would also work
}, [artKey]);
// Artist image → portrait on right. Falls back to cover art.
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
+25 -2
View File
@@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
const HERO_RANDOM_POOL = 32;
// Crossfading background — same layer pattern as FullscreenPlayer
function HeroBg({ url }: { url: string }) {
@@ -54,14 +58,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const navigate = useNavigate();
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, [albumsProp, musicLibraryFilterVersion]);
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
getRandomAlbums(pool)
.then(async raw => {
const list = albumMix
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
: raw;
setAlbums(list);
})
.catch(() => {});
}, [
albumsProp,
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
// Start / restart auto-advance timer
const startTimer = useCallback((len: number) => {
+5 -7
View File
@@ -36,16 +36,14 @@ export default function NowPlayingDropdown() {
});
};
// Poll in background so the badge stays current without opening the dropdown
// Poll only while the dropdown is open AND the page is visible.
useEffect(() => {
if (!isOpen) return;
fetchNowPlaying();
const id = setInterval(fetchNowPlaying, 10000);
const id = setInterval(() => {
if (document.visibilityState === 'visible') fetchNowPlaying();
}, 10000);
return () => clearInterval(id);
}, []);
// Refresh immediately when dropdown is opened
useEffect(() => {
if (isOpen) fetchNowPlaying();
}, [isOpen]);
// Click outside to close
+11 -1
View File
@@ -6,10 +6,11 @@ import {
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import StarRating from './StarRating';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
@@ -37,6 +38,7 @@ export default function PlayerBar() {
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride,
userRatingOverrides, setUserRatingOverride,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
@@ -138,6 +140,14 @@ export default function PlayerBar() {
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
{currentTrack && !isRadio && (
<StarRating
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
className="player-track-rating"
ariaLabel={t('albumDetail.ratingLabel')}
/>
)}
</div>
{currentTrack && !isRadio && (
<button
+2 -1
View File
@@ -214,6 +214,7 @@ export default function QueuePanel() {
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentCoverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt]
@@ -447,7 +448,7 @@ export default function QueuePanel() {
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
</div>
</div>
</div>
+2 -1
View File
@@ -9,7 +9,7 @@ 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,
ChevronDown, Check, Music2, TrendingUp, FolderOpen,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -28,6 +28,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export default function StarRating({
value,
onChange,
disabled = false,
maxStars = 5,
maxSelectable: maxSelectableProp,
labelKey = 'albumDetail.ratingLabel',
ariaLabel,
className = '',
}: {
value: number;
onChange: (rating: number) => void;
disabled?: boolean;
/** Number of star buttons (1…maxStars). Default 5. */
maxStars?: number;
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
maxSelectable?: number;
labelKey?: string;
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
ariaLabel?: string;
className?: string;
}) {
const { t } = useTranslation();
const stars = React.useMemo(
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
[maxStars]
);
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
const [hover, setHover] = React.useState(0);
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
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 cappedValue = Math.min(Math.max(0, value), selectCap);
React.useEffect(() => {
if (value > 0) setSuppressHoverPreview(false);
}, [value]);
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
const handleStarClick = (n: number) => {
if (disabled || n > selectCap) return;
setSuppressHoverPreview(false);
const next = cappedValue === n ? 0 : n;
onChange(next);
setHover(0);
setPulseStar(null);
setClearShrinkStar(null);
if (next === 0) {
setSuppressHoverPreview(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setClearShrinkStar(n));
});
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => setPulseStar(n));
});
}
};
const handleContainerLeave = () => {
setHover(0);
setSuppressHoverPreview(false);
};
return (
<div
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
role="radiogroup"
aria-label={ariaLabel ?? t(labelKey)}
aria-disabled={disabled}
onMouseLeave={disabled ? undefined : handleContainerLeave}
>
{stars.map(n => {
const locked = n > selectCap;
return (
<button
key={n}
type="button"
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
onMouseEnter={() =>
!disabled && !suppressHoverPreview && !locked && setHover(n)
}
onClick={() => handleStarClick(n)}
onAnimationEnd={e => {
if (e.currentTarget !== e.target) return;
const name = e.animationName;
if (name === 'star-rating-star-pulse') {
setPulseStar(s => (s === n ? null : s));
}
if (name === 'star-rating-star-clear-shrink') {
setClearShrinkStar(s => (s === n ? null : s));
}
}}
disabled={disabled || locked}
aria-label={`${n}`}
role="radio"
aria-checked={filled(n)}
>
</button>
);
})}
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ interface ThemeDef {
accent: string;
}
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Games',
themes: [
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { X, Minus, Square } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
const win = getCurrentWindow();
export default function TitleBar() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
return (
<div className="titlebar" data-tauri-drag-region>
<span className="titlebar-title" data-tauri-drag-region>Psysonic</span>
<div className="titlebar-track" data-tauri-drag-region>
{currentTrack && (
<>
<span className="titlebar-track-state">{isPlaying ? '▶' : '⏸'}</span>
<span className="titlebar-track-text truncate">
{currentTrack.artist && `${currentTrack.artist} `}{currentTrack.title}
</span>
</>
)}
</div>
<div className="titlebar-controls">
<button
className="titlebar-btn titlebar-btn-minimize"
onClick={() => win.minimize()}
data-tooltip="Minimize"
data-tooltip-pos="bottom"
>
<Minus size={10} />
</button>
<button
className="titlebar-btn titlebar-btn-maximize"
onClick={() => win.toggleMaximize()}
data-tooltip="Maximize"
data-tooltip-pos="bottom"
>
<Square size={9} />
</button>
<button
className="titlebar-btn titlebar-btn-close"
onClick={() => win.close()}
data-tooltip="Close"
data-tooltip-pos="bottom"
>
<X size={12} />
</button>
</div>
</div>
);
}
+750 -56
View File
@@ -1,5 +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';
@@ -7,6 +8,65 @@ function fmt(s: number): string {
}
const BAR_COUNT = 500;
const SEG_COUNT = 60;
// ── animation state ───────────────────────────────────────────────────────────
type Particle = {
x: number; y: number;
vx: number; vy: number;
life: number; maxLife: number;
size: number;
};
export type AnimState = {
particles: Particle[];
time: number;
lastProgress: number;
angle: number;
};
export function makeAnimState(): AnimState {
return { particles: [], time: 0, lastProgress: 0, angle: 0 };
}
const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
// ── color helper ──────────────────────────────────────────────────────────────
function getColors() {
const s = getComputedStyle(document.documentElement);
return {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
};
}
// ── canvas setup ──────────────────────────────────────────────────────────────
function setupCanvas(
canvas: HTMLCanvasElement,
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
if (w === 0 || h === 0) return null;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
return { ctx, w, h };
}
// ── waveform heights ──────────────────────────────────────────────────────────
function hashStr(str: string): number {
let h = 0x811c9dc5;
@@ -17,125 +77,733 @@ function hashStr(str: string): number {
return h;
}
function makeHeights(trackId: string): Float32Array {
export function makeHeights(trackId: string): Float32Array {
let s = hashStr(trackId);
const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
h[i] = s / 0xffffffff;
}
// Smooth for an organic look
for (let pass = 0; pass < 5; pass++) {
for (let i = 1; i < BAR_COUNT - 1; i++) {
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
}
}
// Normalize to [0.12, 1.0]
let max = 0;
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
if (max > 0) {
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
}
if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
return h;
}
// ── draw functions ────────────────────────────────────────────────────────────
function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
progress: number,
buffered: number,
) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
if (w === 0 || h === 0) return;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const style = getComputedStyle(document.documentElement);
const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244';
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
if (!heights) {
ctx.globalAlpha = 0.3;
ctx.fillStyle = colorUnplayed;
ctx.fillStyle = unplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2);
ctx.globalAlpha = 1;
return;
}
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
// Pass 1 — unplayed (dim)
ctx.globalAlpha = 0.28;
ctx.fillStyle = colorUnplayed;
ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue;
const barH = Math.max(1, heights[i] * h);
const bh = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
// Pass 2 — buffered (slightly brighter)
ctx.globalAlpha = 0.45;
ctx.fillStyle = colorBuffered;
ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue;
const barH = Math.max(1, heights[i] * h);
const bh = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
// Pass 3 — played (accent color + glow)
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = colorAccent;
ctx.shadowColor = colorAccent;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
const barH = Math.max(1, heights[i] * h);
const bh = Math.max(1, heights[i] * h);
const x = x1Of(i);
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
// Fade both edges to transparent using destination-in gradient mask
const fadeW = Math.min(22, w * 0.07);
const mask = ctx.createLinearGradient(0, 0, w, 0);
mask.addColorStop(0, 'transparent');
mask.addColorStop(fadeW / w, 'black');
mask.addColorStop(1 - fadeW / w, 'black');
mask.addColorStop(1, 'transparent');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = mask;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
}
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const lh = 2;
const dotR = 5;
ctx.globalAlpha = 0.35;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - lh / 2, w, lh);
if (buffered > 0) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - lh / 2, buffered * w, lh);
}
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.fillRect(0, cy - lh / 2, progress * w, lh);
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const bh = 4;
const rad = bh / 2;
const y = (h - bh) / 2;
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y, w, bh, rad);
ctx.fill();
if (buffered > 0) {
ctx.globalAlpha = 0.5;
ctx.fillStyle = buffCol;
ctx.beginPath();
ctx.roundRect(0, y, buffered * w, bh, rad);
ctx.fill();
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const bh = Math.min(14, h);
const rad = bh / 2;
const y = (h - bh) / 2;
ctx.globalAlpha = 0.25;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y, w, bh, rad);
ctx.fill();
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.beginPath();
ctx.roundRect(0, y, buffered * w, bh, rad);
ctx.fill();
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const gap = 2;
const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT;
const segH = h * 0.65;
const y = (h - segH) / 2;
const playedIdx = Math.floor(progress * SEG_COUNT);
for (let i = 0; i < SEG_COUNT; i++) {
const frac = i / SEG_COUNT;
const x = i * (segW + gap);
ctx.shadowBlur = 0;
if (frac < progress) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
if (i === playedIdx - 1) {
ctx.shadowColor = played;
ctx.shadowBlur = 5;
}
} else if (frac < buffered) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
} else {
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
}
ctx.beginPath();
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
ctx.fill();
}
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
// ── new styles ────────────────────────────────────────────────────────────────
function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, unplayed } = getColors();
const cy = h / 2;
// Ghost track — barely visible
ctx.globalAlpha = 0.07;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.12;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
if (progress <= 0) return;
const px = progress * w;
// Wide outer glow
ctx.globalAlpha = 0.18;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 22;
ctx.fillRect(0, cy - 5, px, 10);
// Mid glow
ctx.globalAlpha = 0.45;
ctx.shadowBlur = 12;
ctx.fillRect(0, cy - 2.5, px, 5);
// Inner glow
ctx.globalAlpha = 0.85;
ctx.shadowBlur = 5;
ctx.fillRect(0, cy - 1.5, px, 3);
// Bright white core
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.shadowColor = played;
ctx.shadowBlur = 4;
ctx.fillRect(0, cy - 0.75, px, 1.5);
// End-cap flare
ctx.shadowBlur = 16;
ctx.beginPath();
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
function drawPulseWave(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const px = progress * w;
const t = animState.time;
// Base line
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
// Animated pulse centered at playhead
const pulseR = Math.min(38, w * 0.13);
const amp = Math.min(h * 0.42, 5.5);
const sigma = pulseR * 0.42;
const startX = Math.max(0, px - pulseR);
const endX = Math.min(w, px + pulseR);
// Flat played line up to where the wave envelope starts
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 3;
ctx.fillRect(0, cy - 1, startX, 2);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1.5;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(startX, cy);
for (let x = startX; x <= endX; x += 0.75) {
const dx = x - px;
const env = Math.exp(-(dx * dx) / (2 * sigma * sigma));
const wave = env * amp * Math.sin(dx * 0.28 - t * 18);
ctx.lineTo(x, cy - wave);
}
ctx.stroke();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
function drawParticleTrail(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
const px = progress * w;
// Spawn particles at playhead based on movement
const prevPx = animState.lastProgress * w;
const moved = Math.abs(px - prevPx);
const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5));
for (let i = 0; i < spawnN; i++) {
animState.particles.push({
x: px + (Math.random() - 0.5) * 3,
y: cy + (Math.random() - 0.5) * (h * 0.55),
vx: -(Math.random() * 1.0 + 0.3),
vy: (Math.random() - 0.5) * 0.6,
life: 1,
maxLife: 25 + Math.random() * 35,
size: Math.random() * 1.8 + 0.8,
});
}
animState.lastProgress = progress;
// Update + cull
for (const p of animState.particles) {
p.x += p.vx;
p.y += p.vy;
p.vy *= 0.97;
p.life -= 1 / p.maxLife;
}
animState.particles = animState.particles.filter(p => p.life > 0);
if (animState.particles.length > 180) {
animState.particles = animState.particles.slice(-180);
}
// Background line
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
// Played line
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 4;
ctx.fillRect(0, cy - 1, px, 2);
ctx.shadowBlur = 0;
}
// Particles
ctx.shadowColor = played;
for (const p of animState.particles) {
ctx.globalAlpha = p.life * 0.85;
ctx.shadowBlur = 5;
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
// Playhead dot
if (progress > 0) {
const dx = Math.max(5, Math.min(w - 5, px));
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
function drawLiquidFill(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const t = animState.time;
const tubeH = Math.min(13, Math.max(6, h * 0.62));
const tubeR = tubeH / 2;
const y0 = (h - tubeH) / 2;
const y1 = y0 + tubeH;
// Glass tube background
ctx.globalAlpha = 0.18;
ctx.fillStyle = unplayed;
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.fill();
ctx.globalAlpha = 0.3;
ctx.strokeStyle = unplayed;
ctx.lineWidth = 0.8;
ctx.stroke();
if (buffered > 0) {
ctx.save();
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.clip();
ctx.globalAlpha = 0.3;
ctx.fillStyle = buffCol;
ctx.fillRect(0, y0, buffered * w, tubeH);
ctx.restore();
}
if (progress > 0) {
const px = progress * w;
ctx.save();
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.clip();
// Liquid body with animated wave on top surface
const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full
const waveAmp = Math.min(2.0, tubeH * 0.14);
const waveFreq = 0.09;
ctx.beginPath();
ctx.moveTo(-1, y1 + 1);
ctx.lineTo(-1, surfaceY);
for (let x = 0; x <= px + 1; x += 1) {
const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2);
ctx.lineTo(x, surfaceY + wave);
}
ctx.lineTo(px + 1, y1 + 1);
ctx.closePath();
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 9;
ctx.fill();
ctx.shadowBlur = 0;
// Glass highlight on top
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
hl.addColorStop(0, 'rgba(255,255,255,0.28)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.globalAlpha = 0.6;
ctx.fillStyle = hl;
ctx.fillRect(0, y0, px, tubeH * 0.45);
ctx.restore();
}
// Tube outline (on top)
ctx.globalAlpha = 0.5;
ctx.strokeStyle = unplayed;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.roundRect(0, y0, w, tubeH, tubeR);
ctx.stroke();
ctx.globalAlpha = 1;
}
function drawRetroTape(
canvas: HTMLCanvasElement,
progress: number,
buffered: number,
animState: AnimState,
) {
const r = setupCanvas(canvas);
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2;
animState.angle += 0.055;
const reelR = Math.min(h / 2 - 0.5, 9);
// Map progress to a center x that keeps the reel fully within the canvas
const px = reelR + (w - 2 * reelR) * progress;
// Background track
ctx.globalAlpha = 0.3;
ctx.fillStyle = unplayed;
ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.5;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
}
// Played portion — up to the left edge of the reel
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 4;
ctx.fillRect(0, cy - 1, px - reelR, 2);
ctx.shadowBlur = 0;
}
// Spinning reel at playhead
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
// Outer ring
ctx.beginPath();
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
// Hub
const hubR = Math.max(1.5, reelR * 0.28);
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(px, cy, hubR, 0, Math.PI * 2);
ctx.fill();
// Spokes
if (reelR > hubR + 2) {
ctx.lineWidth = 0.9;
ctx.strokeStyle = played;
for (let s = 0; s < 3; s++) {
const a = animState.angle + (s * Math.PI * 2) / 3;
ctx.beginPath();
ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5));
ctx.stroke();
}
}
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
// ── dispatcher ────────────────────────────────────────────────────────────────
export function drawSeekbar(
canvas: HTMLCanvasElement,
style: SeekbarStyle,
heights: Float32Array | null,
progress: number,
buffered: number,
animState?: AnimState,
) {
const anim = animState ?? makeAnimState();
switch (style) {
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
case 'linedot': drawLineDot(canvas, progress, buffered); break;
case 'bar': drawBar(canvas, progress, buffered); break;
case 'thick': drawThick(canvas, progress, buffered); break;
case 'segmented': drawSegmented(canvas, progress, buffered); break;
case 'neon': drawNeon(canvas, progress, buffered); break;
case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break;
case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break;
case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break;
case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break;
}
}
// ── SeekbarPreview (animated, for Settings) ───────────────────────────────────
export function SeekbarPreview({
style,
label,
selected,
onClick,
}: {
style: SeekbarStyle;
label: string;
selected: boolean;
onClick: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
const animState = makeAnimState();
let t = 0;
const tick = () => {
t += 0.016;
animState.time = t;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered, animState);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
}, [style]);
return (
<button
onClick={onClick}
style={{
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
borderRadius: 8,
background: selected
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: 'var(--bg-card, var(--ctp-base))',
padding: '10px 12px 8px',
cursor: 'pointer',
width: 130,
display: 'flex',
flexDirection: 'column',
gap: 6,
alignItems: 'stretch',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<canvas
ref={canvasRef}
style={{ width: '100%', height: 24, display: 'block' }}
/>
<span style={{
fontSize: 11,
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
textAlign: 'center',
fontWeight: selected ? 600 : 400,
}}>
{label}
</span>
</button>
);
}
// ── main component ────────────────────────────────────────────────────────────
interface Props {
trackId: string | undefined;
}
export default function WaveformSeek({ trackId }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(0);
const bufferedRef = useRef(0);
const isDragging = useRef(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(0);
const bufferedRef = useRef(0);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
const [hoverPct, setHoverPct] = useState<number | null>(null);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
progressRef.current = progress;
bufferedRef.current = buffered;
@@ -144,21 +812,47 @@ export default function WaveformSeek({ trackId }: Props) {
heightsRef.current = trackId ? makeHeights(trackId) : null;
}, [trackId]);
// Static styles: redraw on progress / buffered / track changes
useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return;
if (canvasRef.current) {
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
}
}, [progress, buffered, trackId]);
}, [progress, buffered, trackId, seekbarStyle]);
// Animated styles: rAF loop
useEffect(() => {
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
const canvas = canvasRef.current;
if (!canvas) return;
animStateRef.current = makeAnimState();
let rafId: number;
const tick = () => {
animStateRef.current.time += 0.016;
drawSeekbar(
canvas,
seekbarStyle,
heightsRef.current,
progressRef.current,
bufferedRef.current,
animStateRef.current,
);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [seekbarStyle]);
// Resize observer
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ro = new ResizeObserver(() => {
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
ro.observe(canvas);
return () => ro.disconnect();
}, []);
}, [seekbarStyle]);
const trackIdRef = useRef(trackId);
trackIdRef.current = trackId;
+1 -1
View File
@@ -49,7 +49,7 @@ export function useConnectionStatus() {
useEffect(() => {
check();
intervalRef.current = setInterval(check, 30_000);
intervalRef.current = setInterval(check, 120_000);
const handleOnline = () => check();
const handleOffline = () => setStatus('disconnected');
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { useThemeStore, getScheduledTheme } from '../store/themeStore';
export function useThemeScheduler(): string {
const state = useThemeStore();
const [effectiveTheme, setEffectiveTheme] = useState(() => getScheduledTheme(state));
useEffect(() => {
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
if (!state.enableThemeScheduler) return;
const id = setInterval(() => {
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
}, 60_000);
return () => clearInterval(id);
}, [state.enableThemeScheduler, state.theme, state.themeDay, state.themeNight, state.timeDayStart, state.timeNightStart]);
return effectiveTheme;
}
+67 -1
View File
@@ -21,6 +21,7 @@ export const deTranslation = {
playlists: 'Playlists',
mostPlayed: 'Meistgehört',
radio: 'Internetradio',
folderBrowser: 'Ordner-Browser',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
},
@@ -145,6 +146,13 @@ export const deTranslation = {
ratingLabel: 'Bewertung',
enlargeCover: 'Vergrößern',
},
entityRating: {
albumShort: 'Albumbewertung',
artistShort: 'Künstlerbewertung',
albumAriaLabel: 'Albumbewertung',
artistAriaLabel: 'Künstlerbewertung',
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
},
artistDetail: {
back: 'Zurück',
albums: 'Alben',
@@ -244,6 +252,17 @@ export const deTranslation = {
yearTo: 'Bis',
yearFilterClear: 'Jahresfilter zurücksetzen',
yearFilterLabel: 'Jahr',
select: 'Mehrfachauswahl',
startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen',
selectionCount: '{{count}} ausgewählt',
downloadZips: 'ZIPs herunterladen',
addOffline: 'Offline hinzufügen',
downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}',
downloadZipDone: '{{count}} ZIP(s) heruntergeladen',
downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
},
artists: {
title: 'Künstler',
@@ -437,6 +456,8 @@ export const deTranslation = {
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
useCustomTitlebar: 'Eigene Titelleiste',
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.',
nowPlayingEnabled: 'Im Livefenster anzeigen',
@@ -499,6 +520,18 @@ export const deTranslation = {
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
tabSystem: 'System',
tabGeneral: 'Allgemein',
ratingsSectionTitle: 'Bewertungen',
ratingsSkipStarTitle: 'Skip → 1 Stern',
ratingsSkipStarDesc:
'Wird ein Titel mehrmals hintereinander übersprungen, bekommt er automatisch 1★. Nur für noch nicht bewertete Titel.',
ratingsSkipStarThresholdLabel: 'Skips',
ratingsMixFilterTitle: 'Nach Bewertung filtern',
ratingsMixFilterDesc:
'Gering bewertete Titel in {{mix}} und {{albums}} ausblenden. Nochmals auf den gewählten Stern klicken, um den Filter zu deaktivieren.',
ratingsMixMinSong: 'Titel',
ratingsMixMinAlbum: 'Alben',
ratingsMixMinArtist: 'Interpreten',
ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}',
backupTitle: 'Backup & Wiederherstellung',
backupExport: 'Einstellungen exportieren',
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
@@ -530,6 +563,28 @@ export const deTranslation = {
infiniteQueue: 'Endlose Warteschlange',
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
experimental: 'Experimentell',
seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
seekbarWaveform: 'Wellenform',
seekbarLinedot: 'Linie & Punkt',
seekbarBar: 'Balken',
seekbarThick: 'Dicker Balken',
seekbarSegmented: 'Segmentiert',
seekbarNeon: 'Neonröhre',
seekbarPulsewave: 'Pulswelle',
seekbarParticletrail: 'Partikel-Spur',
seekbarLiquidfill: 'Flüssigkeit',
seekbarRetrotape: 'Retro-Band',
themeSchedulerTitle: 'Theme-Zeitplan',
themeSchedulerEnable: 'Theme-Zeitplan aktivieren',
themeSchedulerEnableSub: 'Wechselt automatisch zwischen zwei Themes basierend auf der Uhrzeit',
themeSchedulerDayTheme: 'Tages-Theme',
themeSchedulerDayStart: 'Tag beginnt um',
themeSchedulerNightTheme: 'Nacht-Theme',
themeSchedulerNightStart: 'Nacht beginnt um',
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.',
uiScaleTitle: 'Interface-Skalierung',
uiScaleLabel: 'Zoom',
},
changelog: {
modalTitle: 'Was ist neu',
@@ -685,6 +740,10 @@ export const deTranslation = {
lfmMinutesAgo: 'vor {{n}} Min.',
lfmHoursAgo: 'vor {{n}} Std.',
lfmDaysAgo: 'vor {{n}} Tagen',
topRatedSongs: 'Bestbewertete Songs',
topRatedArtists: 'Bestbewertete Künstler',
noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.',
noRatedArtists: 'Noch keine bewerteten Künstler.',
},
player: {
regionLabel: 'Musikplayer',
@@ -789,6 +848,9 @@ export const deTranslation = {
sortLeast: 'Wenigste Plays zuerst',
loadMore: 'Mehr Alben laden',
noData: 'Noch keine Wiedergabedaten. Fang an zu hören!',
noArtists: 'Alle Künstler herausgefiltert.',
filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)',
filterCompilationsShort: 'Sampler ausblenden',
},
radio: {
title: 'Internetradio',
@@ -820,5 +882,9 @@ export const deTranslation = {
favorite: 'Zu Favoriten hinzufügen',
unfavorite: 'Aus Favoriten entfernen',
noFavorites: 'Keine Lieblingssender.',
}
},
folderBrowser: {
empty: 'Leerer Ordner',
error: 'Laden fehlgeschlagen',
},
};
+67 -1
View File
@@ -22,6 +22,7 @@ export const enTranslation = {
playlists: 'Playlists',
mostPlayed: 'Most Played',
radio: 'Internet Radio',
folderBrowser: 'Folder Browser',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
},
@@ -146,6 +147,13 @@ export const enTranslation = {
ratingLabel: 'Rating',
enlargeCover: 'Enlarge',
},
entityRating: {
albumShort: 'Album rating',
artistShort: 'Artist rating',
albumAriaLabel: 'Album rating',
artistAriaLabel: 'Artist rating',
saveFailed: 'Could not save rating.',
},
artistDetail: {
back: 'Back',
albums: 'Albums',
@@ -245,6 +253,17 @@ export const enTranslation = {
yearTo: 'To',
yearFilterClear: 'Clear year filter',
yearFilterLabel: 'Year',
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',
},
artists: {
title: 'Artists',
@@ -438,6 +457,8 @@ export const enTranslation = {
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
useCustomTitlebar: 'Custom title bar',
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.',
nowPlayingEnabled: 'Show in Now Playing',
@@ -484,6 +505,18 @@ export const enTranslation = {
tabServer: 'Server',
tabSystem: 'System',
tabGeneral: 'General',
ratingsSectionTitle: 'Ratings',
ratingsSkipStarTitle: 'Skip for 1 star',
ratingsSkipStarDesc:
'After several skips in a row, set the track to 1★. Only for tracks not yet rated.',
ratingsSkipStarThresholdLabel: 'Skips',
ratingsMixFilterTitle: 'Filter by rating',
ratingsMixFilterDesc:
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
ratingsMixMinSong: 'Songs',
ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artists',
ratingsMixMinThresholdAria: 'Minimum stars: {{label}}',
backupTitle: 'Backup & Restore',
backupExport: 'Export settings',
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
@@ -531,6 +564,28 @@ export const enTranslation = {
infiniteQueue: 'Infinite Queue',
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
experimental: 'Experimental',
seekbarStyle: 'Seekbar Style',
seekbarStyleDesc: 'Choose the look of the player seek bar',
seekbarWaveform: 'Waveform',
seekbarLinedot: 'Line & Dot',
seekbarBar: 'Bar',
seekbarThick: 'Thick Bar',
seekbarSegmented: 'Segmented',
seekbarNeon: 'Neon Glow',
seekbarPulsewave: 'Pulse Wave',
seekbarParticletrail: 'Particle Trail',
seekbarLiquidfill: 'Liquid Fill',
seekbarRetrotape: 'Retro Tape',
themeSchedulerTitle: 'Auto-Switch Theme',
themeSchedulerEnable: 'Enable Theme Scheduler',
themeSchedulerEnableSub: 'Automatically switch between two themes based on the time of day',
themeSchedulerDayTheme: 'Day Theme',
themeSchedulerDayStart: 'Day Starts At',
themeSchedulerNightTheme: 'Night Theme',
themeSchedulerNightStart: 'Night Starts At',
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.',
uiScaleTitle: 'Interface Scale',
uiScaleLabel: 'Zoom',
},
changelog: {
modalTitle: "What's New",
@@ -686,6 +741,10 @@ export const enTranslation = {
lfmMinutesAgo: '{{n}}m ago',
lfmHoursAgo: '{{n}}h ago',
lfmDaysAgo: '{{n}}d ago',
topRatedSongs: 'Top Rated Songs',
topRatedArtists: 'Top Rated Artists',
noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.',
noRatedArtists: 'No rated artists yet.',
},
player: {
regionLabel: 'Music Player',
@@ -790,6 +849,9 @@ export const enTranslation = {
sortLeast: 'Fewest plays first',
loadMore: 'Load more albums',
noData: 'No play data yet. Start listening!',
noArtists: 'All artists filtered out.',
filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Hide compilations',
},
radio: {
title: 'Internet Radio',
@@ -821,5 +883,9 @@ export const enTranslation = {
favorite: 'Add to favorites',
unfavorite: 'Remove from favorites',
noFavorites: 'No favorite stations.',
}
},
folderBrowser: {
empty: 'Empty folder',
error: 'Failed to load',
},
};
+62 -1
View File
@@ -21,6 +21,7 @@ export const frTranslation = {
playlists: 'Playlists',
mostPlayed: 'Les plus joués',
radio: 'Radio Internet',
folderBrowser: 'Explorateur de dossiers',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
},
@@ -145,6 +146,13 @@ export const frTranslation = {
ratingLabel: 'Note',
enlargeCover: 'Agrandir',
},
entityRating: {
albumShort: 'Note de lalbum',
artistShort: 'Note de lartiste',
albumAriaLabel: 'Note de lalbum',
artistAriaLabel: 'Note de lartiste',
saveFailed: 'Impossible denregistrer la note.',
},
artistDetail: {
back: 'Retour',
albums: 'Albums',
@@ -244,6 +252,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',
},
artists: {
title: 'Artistes',
@@ -499,6 +518,18 @@ export const frTranslation = {
shortcutNativeFullscreen: 'Plein écran natif',
tabSystem: 'Système',
tabGeneral: 'Général',
ratingsSectionTitle: 'Notes',
ratingsSkipStarTitle: 'Passer pour 1 étoile',
ratingsSkipStarDesc:
"Après plusieurs sauts daffilée, le morceau passe à 1 étoile. Uniquement sil n’était pas encore noté.",
ratingsSkipStarThresholdLabel: 'Sauts',
ratingsMixFilterTitle: 'Filtrage par note',
ratingsMixFilterDesc:
"Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.",
ratingsMixMinSong: 'Morceaux',
ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artistes',
ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}',
backupTitle: 'Sauvegarde & Restauration',
backupExport: 'Exporter les paramètres',
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
@@ -530,6 +561,28 @@ export const frTranslation = {
infiniteQueue: 'File infinie',
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
experimental: 'Expérimental',
seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
seekbarWaveform: 'Forme d\'onde',
seekbarLinedot: 'Ligne & point',
seekbarBar: 'Barre',
seekbarThick: 'Barre épaisse',
seekbarSegmented: 'Segmentée',
seekbarNeon: 'Néon',
seekbarPulsewave: 'Onde pulsée',
seekbarParticletrail: 'Traînée de particules',
seekbarLiquidfill: 'Tube liquide',
seekbarRetrotape: 'Bande rétro',
themeSchedulerTitle: 'Planificateur de thème',
themeSchedulerEnable: 'Activer le planificateur de thème',
themeSchedulerEnableSub: 'Bascule automatiquement entre deux thèmes selon l\'heure',
themeSchedulerDayTheme: 'Thème de jour',
themeSchedulerDayStart: 'Début du jour',
themeSchedulerNightTheme: 'Thème de nuit',
themeSchedulerNightStart: 'Début de la nuit',
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.',
uiScaleTitle: "Mise à l'échelle de l'interface",
uiScaleLabel: 'Zoom',
},
changelog: {
modalTitle: 'Quoi de neuf',
@@ -685,6 +738,10 @@ export const frTranslation = {
lfmMinutesAgo: 'il y a {{n}} min',
lfmHoursAgo: 'il y a {{n}}h',
lfmDaysAgo: 'il y a {{n}}j',
topRatedSongs: 'Morceaux les mieux notés',
topRatedArtists: 'Artistes les mieux notés',
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
noRatedArtists: 'Aucun artiste noté.',
},
player: {
regionLabel: 'Lecteur de musique',
@@ -820,5 +877,9 @@ export const frTranslation = {
favorite: 'Ajouter aux favoris',
unfavorite: 'Retirer des favoris',
noFavorites: 'Aucune station favorite.',
}
},
folderBrowser: {
empty: 'Dossier vide',
error: 'Échec du chargement',
},
};
+63 -2
View File
@@ -21,6 +21,7 @@ export const nbTranslation = {
playlists: 'Spillelister',
mostPlayed: 'Mest spilt',
radio: 'Internettradio',
folderBrowser: 'Mappeleser',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
},
@@ -145,6 +146,13 @@ export const nbTranslation = {
ratingLabel: 'Vurdering',
enlargeCover: 'Forstørr',
},
entityRating: {
albumShort: 'Albumvurdering',
artistShort: 'Artistvurdering',
albumAriaLabel: 'Albumvurdering',
artistAriaLabel: 'Artistvurdering',
saveFailed: 'Kunne ikke lagre vurderingen.',
},
artistDetail: {
back: 'Tilbake',
albums: 'Album',
@@ -243,7 +251,18 @@ export const nbTranslation = {
yearFrom: 'Fra',
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År',
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',
},
artists: {
title: 'Artister',
@@ -482,6 +501,18 @@ export const nbTranslation = {
tabServer: 'Tjener',
tabSystem: 'System',
tabGeneral: 'Generelt',
ratingsSectionTitle: 'Vurderinger',
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
ratingsSkipStarDesc:
'Etter flere hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
ratingsSkipStarThresholdLabel: 'Hopp',
ratingsMixFilterTitle: 'Filtrering etter vurdering',
ratingsMixFilterDesc:
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
ratingsMixMinSong: 'Spor',
ratingsMixMinAlbum: 'Album',
ratingsMixMinArtist: 'Artister',
ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
backupExport: 'Eksporter innstillinger',
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst hold filen sikker.',
@@ -529,6 +560,28 @@ export const nbTranslation = {
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
seekbarWaveform: 'Bølgeform',
seekbarLinedot: 'Linje & punkt',
seekbarBar: 'Linje',
seekbarThick: 'Tykk linje',
seekbarSegmented: 'Segmentert',
seekbarNeon: 'Neon',
seekbarPulsewave: 'Pulsbølge',
seekbarParticletrail: 'Partikkelspor',
seekbarLiquidfill: 'Væskerør',
seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Tidsplanlagt tema',
themeSchedulerEnable: 'Aktiver temaplanlegger',
themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt',
themeSchedulerDayTheme: 'Dagtema',
themeSchedulerDayStart: 'Dag starter kl.',
themeSchedulerNightTheme: 'Natttema',
themeSchedulerNightStart: 'Natt starter kl.',
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom',
},
changelog: {
modalTitle: "Nyheter",
@@ -684,6 +737,10 @@ export const nbTranslation = {
lfmMinutesAgo: 'For {{n}}m siden',
lfmHoursAgo: 'For {{n}}t siden',
lfmDaysAgo: 'For {{n}}d siden',
topRatedSongs: 'Høyest vurderte spor',
topRatedArtists: 'Høyest vurderte artister',
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
noRatedArtists: 'Ingen vurderte artister ennå.',
},
player: {
regionLabel: 'Musikkspiller',
@@ -819,5 +876,9 @@ export const nbTranslation = {
favorite: 'Legg til i favoritter',
unfavorite: 'Fjern fra favoritter',
noFavorites: 'Ingen favorittstasjoner.',
}
},
folderBrowser: {
empty: 'Tom mappe',
error: 'Kunne ikke laste',
},
};
+62 -1
View File
@@ -21,6 +21,7 @@ export const nlTranslation = {
playlists: 'Playlists',
mostPlayed: 'Meest gespeeld',
radio: 'Internetradio',
folderBrowser: 'Mappenverkenner',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
},
@@ -145,6 +146,13 @@ export const nlTranslation = {
ratingLabel: 'Beoordeling',
enlargeCover: 'Vergroten',
},
entityRating: {
albumShort: 'Albumbeoordeling',
artistShort: 'Artiestbeoordeling',
albumAriaLabel: 'Albumbeoordeling',
artistAriaLabel: 'Artiestbeoordeling',
saveFailed: 'Beoordeling opslaan mislukt.',
},
artistDetail: {
back: 'Terug',
albums: 'Albums',
@@ -244,6 +252,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',
},
artists: {
title: 'Artiesten',
@@ -499,6 +518,18 @@ export const nlTranslation = {
shortcutNativeFullscreen: 'Systeemvolledig scherm',
tabSystem: 'Systeem',
tabGeneral: 'Algemeen',
ratingsSectionTitle: 'Beoordelingen',
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
ratingsSkipStarDesc:
'Na meerdere overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
ratingsSkipStarThresholdLabel: 'Overslagen',
ratingsMixFilterTitle: 'Filter op beoordeling',
ratingsMixFilterDesc:
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
ratingsMixMinSong: 'Nummers',
ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artiesten',
ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}',
backupTitle: 'Back-up & Herstel',
backupExport: 'Instellingen exporteren',
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
@@ -530,6 +561,28 @@ export const nlTranslation = {
infiniteQueue: 'Oneindige wachtrij',
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
experimental: 'Experimenteel',
seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
seekbarWaveform: 'Golfvorm',
seekbarLinedot: 'Lijn & punt',
seekbarBar: 'Balk',
seekbarThick: 'Dikke balk',
seekbarSegmented: 'Gesegmenteerd',
seekbarNeon: 'Neon',
seekbarPulsewave: 'Pulsgolf',
seekbarParticletrail: 'Deeltjesspoor',
seekbarLiquidfill: 'Vloeistofbuis',
seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Thema-planner',
themeSchedulerEnable: 'Thema-planner inschakelen',
themeSchedulerEnableSub: 'Schakelt automatisch tussen twee thema\'s op basis van de tijd',
themeSchedulerDayTheme: 'Dagthema',
themeSchedulerDayStart: 'Dag begint om',
themeSchedulerNightTheme: 'Nachtthema',
themeSchedulerNightStart: 'Nacht begint om',
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.",
uiScaleTitle: 'Interface schaal',
uiScaleLabel: 'Zoom',
},
changelog: {
modalTitle: 'Wat is nieuw',
@@ -685,6 +738,10 @@ export const nlTranslation = {
lfmMinutesAgo: '{{n}} min geleden',
lfmHoursAgo: '{{n}} uur geleden',
lfmDaysAgo: '{{n}} dagen geleden',
topRatedSongs: 'Best beoordeelde nummers',
topRatedArtists: 'Best beoordeelde artiesten',
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
noRatedArtists: 'Nog geen beoordeelde artiesten.',
},
player: {
regionLabel: 'Muziekspeler',
@@ -820,5 +877,9 @@ export const nlTranslation = {
favorite: 'Toevoegen aan favorieten',
unfavorite: 'Verwijderen uit favorieten',
noFavorites: 'Geen favoriete stations.',
}
},
folderBrowser: {
empty: 'Lege map',
error: 'Laden mislukt',
},
};
+64
View File
@@ -22,6 +22,7 @@ export const ruTranslation = {
playlists: 'Плейлисты',
mostPlayed: 'Часто слушаемое',
radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
},
@@ -147,6 +148,13 @@ export const ruTranslation = {
ratingLabel: 'Оценка',
enlargeCover: 'Увеличить обложку',
},
entityRating: {
albumShort: 'Оценка альбома',
artistShort: 'Оценка исполнителя',
albumAriaLabel: 'Оценка альбома',
artistAriaLabel: 'Оценка исполнителя',
saveFailed: 'Не удалось сохранить оценку.',
},
artistDetail: {
back: 'Назад',
albums: 'Альбомы',
@@ -253,6 +261,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',
},
artists: {
title: 'Исполнители',
@@ -453,6 +472,9 @@ export const ruTranslation = {
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
minimizeToTray: 'Сворачивать в трей',
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
useCustomTitlebar: 'Своя строка заголовка',
useCustomTitlebarDesc:
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
@@ -504,6 +526,18 @@ export const ruTranslation = {
tabServer: 'Сервер',
tabSystem: 'Система',
tabGeneral: 'Общие',
ratingsSectionTitle: 'Рейтинги',
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
ratingsSkipStarDesc:
'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.',
ratingsSkipStarThresholdLabel: 'Скипов',
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
ratingsMixFilterDesc:
'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.',
ratingsMixMinSong: 'Песни',
ratingsMixMinAlbum: 'Альбомы',
ratingsMixMinArtist: 'Исполнители',
ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}',
backupTitle: 'Резервная копия',
backupExport: 'Экспорт настроек',
backupExportDesc:
@@ -553,6 +587,28 @@ export const ruTranslation = {
infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально',
seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
seekbarWaveform: 'Форма волны',
seekbarLinedot: 'Линия и точка',
seekbarBar: 'Полоса',
seekbarThick: 'Толстая полоса',
seekbarSegmented: 'Сегменты',
seekbarNeon: 'Неон',
seekbarPulsewave: 'Пульс-волна',
seekbarParticletrail: 'Частицы',
seekbarLiquidfill: 'Жидкость',
seekbarRetrotape: 'Ретро-лента',
themeSchedulerTitle: 'Расписание тем',
themeSchedulerEnable: 'Включить расписание тем',
themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток',
themeSchedulerDayTheme: 'Дневная тема',
themeSchedulerDayStart: 'День начинается в',
themeSchedulerNightTheme: 'Ночная тема',
themeSchedulerNightStart: 'Ночь начинается в',
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб',
},
changelog: {
modalTitle: 'Что нового',
@@ -742,6 +798,10 @@ export const ruTranslation = {
lfmMinutesAgo: '{{n}} мин назад',
lfmHoursAgo: '{{n}} ч назад',
lfmDaysAgo: '{{n}} дн. назад',
topRatedSongs: 'Лучшие по рейтингу треки',
topRatedArtists: 'Лучшие по рейтингу исполнители',
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
noRatedArtists: 'Нет оценённых исполнителей.',
},
player: {
regionLabel: 'Плеер',
@@ -875,4 +935,8 @@ export const ruTranslation = {
unfavorite: 'Убрать из избранного',
noFavorites: 'Избранных станций нет.',
},
folderBrowser: {
empty: 'Папка пуста',
error: 'Ошибка загрузки',
},
};
+62 -1
View File
@@ -21,6 +21,7 @@ export const zhTranslation = {
playlists: '播放列表',
mostPlayed: '最常播放',
radio: '网络电台',
folderBrowser: '文件夹浏览器',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
},
@@ -145,6 +146,13 @@ export const zhTranslation = {
ratingLabel: '评分',
enlargeCover: '放大',
},
entityRating: {
albumShort: '专辑评分',
artistShort: '艺人评分',
albumAriaLabel: '专辑评分',
artistAriaLabel: '艺人评分',
saveFailed: '无法保存评分。',
},
artistDetail: {
back: '返回',
albums: '专辑',
@@ -244,6 +252,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',
},
artists: {
title: '艺术家',
@@ -479,6 +498,18 @@ export const zhTranslation = {
tabServer: '服务器',
tabSystem: '系统',
tabGeneral: '通用',
ratingsSectionTitle: '评分',
ratingsSkipStarTitle: '跳过以评 1 星',
ratingsSkipStarDesc:
'连续多次跳过后将曲目设为 1 星。仅适用于此前未评分的曲目。',
ratingsSkipStarThresholdLabel: '跳过次数',
ratingsMixFilterTitle: '按评分筛选',
ratingsMixFilterDesc:
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
ratingsMixMinSong: '歌曲',
ratingsMixMinAlbum: '专辑',
ratingsMixMinArtist: '艺人',
ratingsMixMinThresholdAria: '最低星数:{{label}}',
backupTitle: '备份与恢复',
backupExport: '导出设置',
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
@@ -526,6 +557,28 @@ export const zhTranslation = {
infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性',
seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观',
seekbarWaveform: '波形',
seekbarLinedot: '线条与点',
seekbarBar: '条形',
seekbarThick: '粗条形',
seekbarSegmented: '分段式',
seekbarNeon: '霓虹',
seekbarPulsewave: '脉冲波',
seekbarParticletrail: '粒子轨迹',
seekbarLiquidfill: '液体填充',
seekbarRetrotape: '复古磁带',
themeSchedulerTitle: '主题定时切换',
themeSchedulerEnable: '启用主题定时器',
themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换',
themeSchedulerDayTheme: '白天主题',
themeSchedulerDayStart: '白天开始时间',
themeSchedulerNightTheme: '夜晚主题',
themeSchedulerNightStart: '夜晚开始时间',
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放',
},
changelog: {
modalTitle: '新功能',
@@ -681,6 +734,10 @@ export const zhTranslation = {
lfmMinutesAgo: '{{n}} 分钟前',
lfmHoursAgo: '{{n}} 小时前',
lfmDaysAgo: '{{n}} 天前',
topRatedSongs: '最高评分歌曲',
topRatedArtists: '最高评分艺人',
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
noRatedArtists: '暂无已评分艺人。',
},
player: {
regionLabel: '音乐播放器',
@@ -816,5 +873,9 @@ export const zhTranslation = {
favorite: '添加到收藏',
unfavorite: '从收藏移除',
noFavorites: '没有收藏的电台。',
}
},
folderBrowser: {
empty: '空文件夹',
error: '加载失败',
},
};
+44
View File
@@ -13,6 +13,7 @@ import AlbumHeader from '../components/AlbumHeader';
import AlbumTrackList from '../components/AlbumTrackList';
import { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function sanitizeFilename(name: string): string {
return name
@@ -33,6 +34,7 @@ export default function AlbumDetail() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -52,6 +54,11 @@ export default function AlbumDetail() {
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineJobs = useOfflineStore(s => s.jobs);
const serverId = auth.activeServerId ?? '';
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const [albumEntityRating, setAlbumEntityRating] = useState(0);
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
if (!album) return 'none';
@@ -90,6 +97,11 @@ export default function AlbumDetail() {
}).catch(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!id) return;
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
}, [id, album?.album.id, album?.album.userRating]);
const handlePlayAll = () => {
if (!album) return;
const albumGenre = album.album.genre;
@@ -126,9 +138,37 @@ const handleEnqueueAll = () => {
const handleRate = async (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
await setRating(songId, rating);
};
const handleAlbumEntityRating = async (rating: number) => {
if (!album || album.album.id !== id) return;
const albumId = album.album.id;
const ratingAtStart = album.album.userRating ?? 0;
setAlbumEntityRating(rating);
if (albumEntityRatingSupport !== 'full') return;
try {
await setRating(albumId, rating);
setAlbum(cur =>
cur && cur.album.id === albumId
? { ...cur, album: { ...cur.album, userRating: rating } }
: cur,
);
} catch (err) {
setAlbumEntityRating(ratingAtStart);
setEntityRatingSupport(serverId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
}
};
const handleBio = async () => {
if (!album) return;
if (bio) { setBioOpen(true); return; }
@@ -270,6 +310,9 @@ const handleEnqueueAll = () => {
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
entityRatingValue={albumEntityRating}
onEntityRatingChange={handleAlbumEntityRating}
entityRatingSupport={albumEntityRatingSupport}
/>
{offlineStorageFull && (
<div className="offline-storage-full-banner" role="alert">
@@ -290,6 +333,7 @@ const handleEnqueueAll = () => {
currentTrack={currentTrack}
isPlaying={isPlaying}
ratings={ratings}
userRatingOverrides={userRatingOverrides}
starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
+171 -52
View File
@@ -1,16 +1,25 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { X } from 'lucide-react';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
const PAGE_SIZE = 30;
const CURRENT_YEAR = new Date().getFullYear();
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
@@ -20,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function Albums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -30,6 +44,73 @@ export default function Albums() {
const [yearTo, setYearTo] = useState('');
const observerTarget = useRef<HTMLDivElement>(null);
// ── 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 selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
try {
const url = buildDownloadUrl(album.id);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
done++;
} catch (e) {
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
// ── Data loading ─────────────────────────────────────────────────────────
const genreFiltered = selectedGenres.length > 0;
const fromNum = parseInt(yearFrom, 10);
const toNum = parseInt(yearTo, 10);
@@ -108,58 +189,87 @@ export default function Albums() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{!yearActive && sortOptions.map(o => (
<button
key={o.value}
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
onClick={() => setSort(o.value)}
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
{o.label}
</button>
))}
{/* Year range filter */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
style={{ padding: '4px 6px' }}
>
<X size={13} />
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
)}
</div>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<>
{!yearActive && sortOptions.map(o => (
<button
key={o.value}
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
onClick={() => setSort(o.value)}
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
{o.label}
</button>
))}
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
style={{ padding: '4px 6px' }}
>
<X size={13} />
</button>
)}
</div>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
</div>
</div>
@@ -170,7 +280,15 @@ export default function Albums() {
) : (
<>
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
{albums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
@@ -179,6 +297,7 @@ export default function Albums() {
)}
</>
)}
</div>
);
}
+62 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
@@ -14,6 +14,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import { invalidateCoverArt } from '../utils/imageCache';
import { showToast } from '../utils/toast';
import StarRating from '../components/StarRating';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -71,29 +72,70 @@ export default function ArtistDetail() {
const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
const [artistEntityRating, setArtistEntityRating] = useState(0);
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
setInfo(null);
setTopSongs([]);
setFeaturedAlbums([]);
getArtist(id).then(artistData => {
if (cancelled) return;
setArtist(artistData.artist);
setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
return Promise.all([
getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => []),
]);
}).then(([artistInfo, songsData]) => {
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
// Render the page immediately from local data
setLoading(false);
// Fetch artist info (may trigger slow external lookup on the server)
// and top songs in the background — do not block rendering
getArtistInfo(id).then(artistInfo => {
if (!cancelled) setInfo(artistInfo ?? null);
}).catch(() => {});
getTopSongs(artistData.artist.name).then(songsData => {
if (!cancelled) setTopSongs(songsData ?? []);
}).catch(() => {});
}).catch(err => {
console.error(err);
setLoading(false);
if (!cancelled) { console.error(err); setLoading(false); }
});
return () => { cancelled = true; };
}, [id]);
useEffect(() => {
if (!id) return;
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
}, [id, artist?.id, artist?.userRating]);
const handleArtistEntityRating = async (rating: number) => {
if (!artist || artist.id !== id) return;
const artistId = artist.id;
const ratingAtStart = artist.userRating ?? 0;
setArtistEntityRating(rating);
if (artistEntityRatingSupport !== 'full') return;
try {
await setRating(artistId, rating);
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
} catch (err) {
setArtistEntityRating(ratingAtStart);
setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
}
};
// "Also Featured On" — loaded in background after main content renders
useEffect(() => {
if (!id || !artist) return;
@@ -351,6 +393,16 @@ export default function ArtistDetail() {
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
</div>
<div className="artist-detail-entity-rating">
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
<StarRating
value={artistEntityRating}
onChange={handleArtistEntityRating}
disabled={artistEntityRatingSupport === 'track_only'}
labelKey="entityRating.artistAriaLabel"
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
+32 -5
View File
@@ -4,11 +4,12 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import CachedImage from '../components/CachedImage';
import {
getStarred, getInternetRadioStations,
getStarred, getInternetRadioStations, setRating,
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
buildCoverArtUrl, coverArtCacheKey,
} 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 { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -20,7 +21,8 @@ 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: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
@@ -39,14 +41,23 @@ export default function Favorites() {
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
const [ratings, setRatings] = useState<Record<string, number>>({});
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const psyDrag = useDragDrop();
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
};
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
setStarredOverride(id, false);
@@ -179,11 +190,20 @@ export default function Favorites() {
);
}
if (key === 'remove') return <div key="remove" />;
const isCentered = key === 'duration';
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={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<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>
@@ -255,6 +275,13 @@ 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 'rating': return (
<StarRating
key="rating"
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => handleRate(song.id, r)}
/>
);
case 'duration': return (
<div key="duration" className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
+171
View File
@@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic';
import { usePlayerStore, Track } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
// ── types ─────────────────────────────────────────────────────────────────────
type Column = {
id: string;
name: string;
items: SubsonicDirectoryEntry[];
selectedId: string | null;
loading: boolean;
error: boolean;
};
// ── helpers ───────────────────────────────────────────────────────────────────
function entryToTrack(e: SubsonicDirectoryEntry): Track {
return {
id: e.id,
title: e.title,
artist: e.artist ?? '',
album: e.album ?? '',
albumId: e.albumId ?? '',
artistId: e.artistId,
coverArt: e.coverArt,
duration: e.duration ?? 0,
track: e.track,
year: e.year,
bitRate: e.bitRate,
suffix: e.suffix,
genre: e.genre,
starred: e.starred,
userRating: e.userRating,
};
}
// ── component ─────────────────────────────────────────────────────────────────
export default function FolderBrowser() {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column[]>([]);
const wrapperRef = useRef<HTMLDivElement>(null);
const playTrack = usePlayerStore(s => s.playTrack);
// ── root: load music folders on mount ─────────────────────────────────────
useEffect(() => {
const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false };
setColumns([placeholder]);
getMusicFolders()
.then(folders => {
const items: SubsonicDirectoryEntry[] = folders.map(f => ({
id: f.id,
title: f.name,
isDir: true,
}));
setColumns([{ ...placeholder, items, loading: false }]);
})
.catch(() => {
setColumns([{ ...placeholder, items: [], loading: false, error: true }]);
});
}, []);
// ── auto-scroll to newly added column ─────────────────────────────────────
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
}, [columns.length]);
// ── click a directory ──────────────────────────────────────────────────────
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
// Mark selected + truncate columns after this one + add loading column
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 },
]);
// 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);
fetchItems
.then(items => {
setColumns(prev => {
const idx = prev.findIndex(c => c.id === item.id && c.loading);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = { ...next[idx], items, loading: false };
return next;
});
})
.catch(() => {
setColumns(prev => {
const idx = prev.findIndex(c => c.id === item.id && c.loading);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = { ...next[idx], loading: false, error: true };
return next;
});
});
}, []);
// ── 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 ─────────────────────────────────────────────────────────────────
return (
<div className="folder-browser">
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
<div className="folder-browser-columns" ref={wrapperRef}>
{columns.map((col, colIndex) => (
<div key={`${col.id}-${colIndex}`} className="folder-col">
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
</div>
) : col.error ? (
<div className="folder-col-status folder-col-error">
{t('folderBrowser.error')}
</div>
) : col.items.length === 0 ? (
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
) : (
col.items.map(item => {
const isSelected = col.selectedId === item.id;
return (
<button
key={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}`}
onClick={() =>
item.isDir
? handleDirClick(colIndex, item)
: handleFileClick(colIndex, item)
}
>
<span className="folder-col-icon">
{item.isDir
? isSelected
? <FolderOpen size={14} />
: <Folder size={14} />
: <Music size={14} />}
</span>
<span className="folder-col-name">{item.title}</span>
{item.isDir && (
<ChevronRight size={12} className="folder-col-chevron" />
)}
</button>
);
})
)}
</div>
))}
</div>
</div>
);
}
+57 -24
View File
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -23,30 +32,50 @@ export default function Home() {
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
]).then(([s, n, r, f, rp, artists]) => {
setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f);
setRecentlyPlayed(rp);
// Pick 16 random artists via Fisher-Yates shuffle
const shuffled = [...artists];
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]];
let cancelled = false;
setLoading(true);
(async () => {
try {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
const [s, n, rRaw, f, rp, artists] = await Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', randomSize).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
]);
if (cancelled) return;
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
setMostPlayed(f);
setRecentlyPlayed(rp);
const shuffled = [...artists];
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]];
}
setRandomArtists(shuffled.slice(0, 16));
} catch {
/* ignore */
} finally {
if (!cancelled) setLoading(false);
}
setRandomArtists(shuffled.slice(0, 16));
setLoading(false);
}).catch(() => setLoading(false));
}, [musicLibraryFilterVersion, homeSections]);
})();
return () => { cancelled = true; };
}, [
musicLibraryFilterVersion,
homeSections,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
@@ -55,7 +84,10 @@ export default function Home() {
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
const mixCfg = getMixMinRatingsConfigFromAuth();
const batch =
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) {
console.error('Failed to load more', e);
@@ -134,6 +166,7 @@ export default function Home() {
{isVisible('mostPlayed') && (
<AlbumRow
title={t('home.mostPlayed')}
titleLink="/most-played"
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText={t('home.loadMore')}
+33 -5
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp } from 'lucide-react';
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound } from 'lucide-react';
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
@@ -16,11 +16,24 @@ interface ArtistEntry {
totalPlays: number;
}
function deriveTopArtists(albums: SubsonicAlbum[]): ArtistEntry[] {
const COMPILATION_NAMES = new Set([
'various artists', 'various', 'va', 'v.a.', 'v.a',
'diverse artister', 'diversos artistas', 'artistes variés',
'vários artistas', 'verschiedene künstler', 'verscheidene artiesten',
'compilations', 'soundtrack', 'original soundtrack', 'ost',
'original motion picture soundtrack', 'original score',
]);
function isCompilation(name: string): boolean {
return COMPILATION_NAMES.has(name.toLowerCase().trim());
}
function deriveTopArtists(albums: SubsonicAlbum[], filterCompilations: boolean): ArtistEntry[] {
const map = new Map<string, ArtistEntry>();
for (const a of albums) {
const plays = a.playCount ?? 0;
if (plays === 0) continue;
if (filterCompilations && isCompilation(a.artist ?? '')) continue;
const entry = map.get(a.artistId);
if (entry) {
entry.totalPlays += plays;
@@ -46,8 +59,9 @@ export default function MostPlayed() {
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [sortAsc, setSortAsc] = useState(false); // false = most plays first
const [filterCompilations, setFilterCompilations] = useState(false);
const topArtists = deriveTopArtists(albums).slice(0, 10);
const topArtists = deriveTopArtists(albums, filterCompilations).slice(0, 10);
const load = useCallback(async () => {
setLoading(true);
@@ -96,9 +110,23 @@ export default function MostPlayed() {
</div>
{/* ── Top Artists ── */}
{!loading && topArtists.length > 0 && (
{!loading && (
<section className="mp-section">
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
<div className="mp-section-header">
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
<button
className={`btn btn-ghost mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
onClick={() => setFilterCompilations(v => !v)}
data-tooltip={t('mostPlayed.filterCompilations')}
data-tooltip-pos="left"
>
<UsersRound size={14} />
{t('mostPlayed.filterCompilationsShort')}
</button>
</div>
{topArtists.length === 0 && (
<div className="empty-state" style={{ padding: '12px 0' }}>{t('mostPlayed.noArtists')}</div>
)}
<div className="mp-artist-grid">
{topArtists.map((artist, i) => (
<button
+103 -4
View File
@@ -1,12 +1,22 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
const PAGE_SIZE = 30;
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
@@ -17,6 +27,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function NewReleases() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -25,6 +40,53 @@ export default function NewReleases() {
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
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 selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
try {
const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
done++;
} catch (e) {
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const load = useCallback(async (offset: number, append = false) => {
setLoading(true);
try {
@@ -71,8 +133,37 @@ export default function NewReleases() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('sidebar.newReleases')}</h1>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('sidebar.newReleases')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
</div>
</div>
{loading && albums.length === 0 ? (
@@ -82,7 +173,15 @@ export default function NewReleases() {
) : (
<>
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
{albums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
+2 -1
View File
@@ -211,6 +211,7 @@ export default function NowPlaying() {
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const isPlaying = usePlayerStore(s => s.isPlaying);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
@@ -292,7 +293,7 @@ export default function NowPlaying() {
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
{renderStars(currentTrack.userRating)}
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
<button onClick={toggleStar} className="np-star-btn"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
+18 -23
View File
@@ -21,6 +21,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import StarRating from '../components/StarRating';
function sanitizeFilename(name: string): string {
return name
@@ -50,23 +51,6 @@ function codecLabel(song: SubsonicSong): string {
return parts.join(' · ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const [hover, setHover] = React.useState(0);
return (
<div className="star-rating">
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
></button>
))}
</div>
);
}
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -74,7 +58,7 @@ const PL_COLUMNS: readonly ColDef[] = [
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
@@ -85,7 +69,7 @@ export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
useShallow(s => ({
playTrack: s.playTrack,
enqueue: s.enqueue,
@@ -94,6 +78,7 @@ export default function PlaylistDetail() {
isPlaying: s.isPlaying,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
}))
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
@@ -370,6 +355,7 @@ export default function PlaylistDetail() {
// ── Rating / Star ─────────────────────────────────────────────
const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
};
@@ -750,8 +736,17 @@ export default function PlaylistDetail() {
if (key === 'delete') return <div key="delete" />;
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={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<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 && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
@@ -851,7 +846,7 @@ export default function PlaylistDetail() {
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
@@ -917,7 +912,7 @@ export default function PlaylistDetail() {
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />;
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
})}
</div>
+131 -16
View File
@@ -1,42 +1,115 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
const ALBUM_FETCH_OVERSHOOT = 100;
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
const GENRE_UNION_PREFILTER_CAP = 250;
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
// Fisher-Yates shuffle
for (let i = union.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]];
}
return union.slice(0, ALBUM_COUNT);
const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
return filtered.slice(0, ALBUM_COUNT);
}
export default function RandomAlbums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
const mixMinRatingArtist = auth.mixMinRatingArtist;
const serverId = auth.activeServerId ?? '';
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const loadingRef = useRef(false);
const filtered = selectedGenres.length > 0;
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 selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
try {
const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
done++;
} catch (e) {
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const load = useCallback(async (genres: string[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMixActive =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
const data = genres.length > 0
? await fetchByGenres(genres)
: await getAlbumList('random', ALBUM_COUNT);
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
setAlbums(data);
} catch (e) {
console.error(e);
@@ -44,24 +117,58 @@ export default function RandomAlbums() {
loadingRef.current = false;
setLoading(false);
}
}, [musicLibraryFilterVersion]);
}, [
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('randomAlbums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className="btn btn-surface"
onClick={() => load(selectedGenres)}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
</>
)}
<button
className="btn btn-ghost"
onClick={() => load(selectedGenres)}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
</div>
</div>
@@ -72,7 +179,15 @@ export default function RandomAlbums() {
</div>
) : (
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
{albums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
)}
</div>
+37 -9
View File
@@ -1,10 +1,15 @@
import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import React, { useEffect, useMemo, useState } from 'react';
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
import {
fetchRandomMixSongsUntilFull,
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from '../utils/mixRatingFilter';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -35,7 +40,26 @@ export default function RandomMix() {
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const {
excludeAudiobooks,
setExcludeAudiobooks,
customGenreBlacklist,
setCustomGenreBlacklist,
mixMinRatingFilterEnabled,
mixMinRatingSong,
mixMinRatingAlbum,
mixMinRatingArtist,
} = useAuthStore();
const mixRatingCfg = useMemo(
() => ({
enabled: mixMinRatingFilterEnabled,
minSong: mixMinRatingSong,
minAlbum: mixMinRatingAlbum,
minArtist: mixMinRatingArtist,
}),
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
@@ -56,11 +80,11 @@ export default function RandomMix() {
const fetchSongs = () => {
setLoading(true);
setSongs([]);
getRandomSongs(50)
.then(fetched => {
setSongs(fetched);
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
.then(list => {
setSongs(list);
const st = new Set<string>();
fetched.forEach(s => { if (s.starred) st.add(s.id); });
list.forEach(s => { if (s.starred) st.add(s.id); });
setStarredSongs(st);
setLoading(false);
})
@@ -97,6 +121,7 @@ export default function RandomMix() {
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
return true;
});
@@ -132,8 +157,11 @@ export default function RandomMix() {
setGenreMixComplete(false);
setGenreMixSongs([]);
try {
const fetched = await getRandomSongs(50, genre, 45000);
setGenreMixSongs(fetched);
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
genre,
timeout: 45000,
});
setGenreMixSongs(list);
} catch {}
setGenreMixLoading(false);
setGenreMixComplete(true);
+276 -3
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
@@ -17,8 +17,10 @@ import { useHotCacheStore } from '../store/hotCacheStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker';
import { useAuthStore, ServerProfile } from '../store/authStore';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
@@ -31,6 +33,7 @@ import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import Equalizer from '../components/Equalizer';
import StarRating from '../components/StarRating';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
@@ -199,6 +202,13 @@ export default function Settings() {
const clearAllOffline = useOfflineStore(s => s.clearAll);
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
const hotCacheEntries = useHotCacheStore(s => s.entries);
const [isTilingWm, setIsTilingWm] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
}, []);
const hotCacheTrackCount = useMemo(() => {
if (!serverId) return 0;
const prefix = `${serverId}:`;
@@ -615,6 +625,21 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
{IS_LINUX && !isTilingWm && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.useCustomTitlebar')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.useCustomTitlebarDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.useCustomTitlebar')}>
<input type="checkbox" checked={auth.useCustomTitlebar} onChange={e => auth.setUseCustomTitlebar(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</>
)}
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
@@ -761,6 +786,111 @@ export default function Settings() {
</div>
</section>
{/* Ratings (single block under Random Mix) */}
<section className="settings-section">
<div className="settings-section-header">
<Star size={18} />
<h2>{t('settings.ratingsSectionTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{auth.skipStarOnManualSkipsEnabled && (
<>
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
{t('settings.ratingsSkipStarThresholdLabel')}
</label>
<input
id="settings-skip-star-threshold"
className="input"
type="number"
min={1}
max={99}
value={auth.skipStarManualSkipThreshold}
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
/>
</>
)}
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
<input
type="checkbox"
checked={auth.skipStarOnManualSkipsEnabled}
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.ratingsMixFilterDesc', {
mix: t('sidebar.randomMix'),
albums: t('sidebar.randomAlbums'),
})}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
<input
type="checkbox"
checked={auth.mixMinRatingFilterEnabled}
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
{auth.mixMinRatingFilterEnabled && (
<>
<div className="settings-section-divider" />
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: '1rem 0.75rem',
alignItems: 'start',
}}
>
{([
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
] as const).map(row => (
<div
key={row.key}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
minWidth: 0,
textAlign: 'center',
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
<StarRating
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
value={row.value}
onChange={row.set}
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
/>
</div>
))}
</div>
</>
)}
</div>
</section>
<HomeCustomizer />
</>
)}
@@ -1093,10 +1223,130 @@ export default function Settings() {
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
{theme.enableThemeScheduler && (
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.themeSchedulerActiveHint')}
</div>
)}
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Clock size={18} />
<h2>{t('settings.themeSchedulerTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeSchedulerEnableSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.themeSchedulerEnable')}>
<input type="checkbox" checked={theme.enableThemeScheduler} onChange={e => theme.setEnableThemeScheduler(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
{theme.enableThemeScheduler && (() => {
const themeOptions = THEME_GROUPS.flatMap(g =>
g.themes.map(th => ({ value: th.id, label: th.label, group: g.group }))
);
const use12h = i18n.language === 'en';
const hourOptions = Array.from({ length: 24 }, (_, i) => {
const value = String(i).padStart(2, '0');
const label = use12h
? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}`
: value;
return { value, label };
});
const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m }));
const dayH = theme.timeDayStart.split(':')[0];
const dayM = theme.timeDayStart.split(':')[1];
const nightH = theme.timeNightStart.split(':')[0];
const nightM = theme.timeNightStart.split(':')[1];
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label>
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
</div>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} />
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} />
</div>
</div>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label>
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
</div>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
</div>
</div>
</div>
);
})()}
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<ZoomIn size={18} />
<h2>{t('settings.uiScaleTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
{Math.round(fontStore.uiScale * 100)}%
</span>
</div>
<input
type="range"
min={0.8}
max={1.5}
step={0.05}
value={fontStore.uiScale}
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
className="ui-scale-slider"
/>
<div style={{ position: 'relative', height: 24 }}>
{[80, 90, 100, 110, 125, 150].map(p => {
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
const active = Math.round(fontStore.uiScale * 100) === p;
return (
<button
key={p}
className="btn btn-ghost"
style={{
position: 'absolute',
left: `${pct}%`,
transform: 'translateX(-50%)',
fontSize: 11,
padding: '2px 6px',
opacity: active ? 1 : 0.5,
color: active ? 'var(--accent)' : undefined,
}}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
</div>
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Type size={18} />
@@ -1131,6 +1381,29 @@ export default function Settings() {
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.seekbarStyle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.seekbarStyleDesc')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
<SeekbarPreview
key={style}
style={style}
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
selected={auth.seekbarStyle === style}
onClick={() => auth.setSeekbarStyle(style)}
/>
))}
</div>
</div>
</section>
<SidebarCustomizer />
</>
)}
+4 -1
View File
@@ -3,6 +3,7 @@ import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, Sub
import AlbumRow from '../components/AlbumRow';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -32,6 +33,7 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() {
const { t } = useTranslation();
const navigate = useNavigate();
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
@@ -282,6 +284,7 @@ export default function Statistics() {
albums={highest}
onLoadMore={() => loadMore('highest', highest, setHighest)}
moreText={t('statistics.loadMore')}
showRating
/>
{/* Last.fm Stats */}
@@ -363,7 +366,7 @@ export default function Statistics() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{lfmRecentTracks.map((track, i) => (
{lfmRecentTracks.slice(0, 3).map((track, i) => (
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
+139
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import { usePlayerStore } from './playerStore';
export interface ServerProfile {
@@ -11,6 +12,8 @@ export interface ServerProfile {
password: string;
}
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
interface AuthState {
// Multi-server
servers: ServerProfile[];
@@ -42,12 +45,15 @@ interface AuthState {
minimizeToTray: boolean;
discordRichPresence: boolean;
enableAppleMusicCoversDiscord: boolean;
useCustomTitlebar: boolean;
nowPlayingEnabled: boolean;
lyricsServerFirst: boolean;
showFullscreenLyrics: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
seekbarStyle: SeekbarStyle;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
@@ -58,6 +64,29 @@ interface AuthState {
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string;
/** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */
skipStarOnManualSkipsEnabled: boolean;
/** Manual skips per track before applying rating 1 (when enabled). */
skipStarManualSkipThreshold: number;
/**
* Manual Next-count per track for skip1. Key = `${serverId}\\u001f${trackId}`
* (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached.
*/
skipStarManualSkipCountsByKey: Record<string, number>;
/** Increment skip count for current server + track; clears stored count when threshold reached. */
recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null;
/** Drop persisted skip count for this track on the active server (e.g. natural playback end). */
clearSkipStarManualCountForTrack: (trackId: string) => void;
/** Random mixes, random albums, home hero: drop nonzero ratings at or below peraxis thresholds (0 = unrated, kept). */
mixMinRatingFilterEnabled: boolean;
/** 0 = ignore; 13 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
mixMinRatingSong: number;
/** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
/**
@@ -68,6 +97,13 @@ interface AuthState {
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
musicLibraryFilterVersion: number;
/**
* Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style).
* Absent key = not probed yet (`unknown` in UI).
*/
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
@@ -105,16 +141,24 @@ interface AuthState {
setMinimizeToTray: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
setUseCustomTitlebar: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setEnableHiRes: (v: boolean) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
setSkipStarManualSkipThreshold: (v: number) => void;
setMixMinRatingFilterEnabled: (v: boolean) => void;
setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
logout: () => void;
@@ -128,6 +172,33 @@ function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
function clampMixFilterMinStars(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
}
function clampSkipStarThreshold(v: number): number {
if (!Number.isFinite(v)) return 3;
return Math.max(1, Math.min(99, Math.round(v)));
}
function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string {
return `${serverId ?? ''}\u001f${trackId}`;
}
function sanitizeSkipStarCounts(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const next: Record<string, number> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
const n = Number(v);
if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000);
}
return next;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -156,19 +227,29 @@ export const useAuthStore = create<AuthState>()(
minimizeToTray: false,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
useCustomTitlebar: false,
nowPlayingEnabled: false,
lyricsServerFirst: true,
showFullscreenLyrics: true,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
seekbarStyle: 'waveform',
enableHiRes: false,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
hotCacheDownloadDir: '',
skipStarOnManualSkipsEnabled: false,
skipStarManualSkipThreshold: 3,
skipStarManualSkipCountsByKey: {},
mixMinRatingFilterEnabled: false,
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
entityRatingSupportByServer: {},
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -190,10 +271,12 @@ export const useAuthStore = create<AuthState>()(
set(s => {
const newServers = s.servers.filter(srv => srv.id !== id);
const switchedAway = s.activeServerId === id;
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
return {
servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
isLoggedIn: switchedAway ? false : s.isLoggedIn,
entityRatingSupportByServer: entityRatingRest,
};
});
},
@@ -240,18 +323,58 @@ export const useAuthStore = create<AuthState>()(
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
setSkipStarOnManualSkipsEnabled: (v) =>
set({
skipStarOnManualSkipsEnabled: v,
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
}),
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
recordSkipStarManualAdvance: (trackId: string) => {
const s = get();
if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null;
const key = skipStarCountStorageKey(s.activeServerId, trackId);
const prev = s.skipStarManualSkipCountsByKey[key] ?? 0;
const threshold = s.skipStarManualSkipThreshold;
const next = prev + 1;
if (next >= threshold) {
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
set({ skipStarManualSkipCountsByKey: rest });
return { crossedThreshold: true };
}
set({
skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next },
});
return { crossedThreshold: false };
},
clearSkipStarManualCountForTrack: (trackId: string) => {
const s = get();
const key = skipStarCountStorageKey(s.activeServerId, trackId);
if (s.skipStarManualSkipCountsByKey[key] === undefined) return;
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
set({ skipStarManualSkipCountsByKey: rest });
},
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setMusicFolders: (folders) => {
const sid = get().activeServerId;
set(s => {
@@ -275,6 +398,11 @@ export const useAuthStore = create<AuthState>()(
}));
},
setEntityRatingSupport: (serverId, level) =>
set(s => ({
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
})),
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => {
@@ -296,6 +424,17 @@ export const useAuthStore = create<AuthState>()(
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
return rest;
},
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
});
},
}
)
);
+4
View File
@@ -6,6 +6,8 @@ export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'spac
interface FontState {
font: FontId;
setFont: (font: FontId) => void;
uiScale: number;
setUiScale: (scale: number) => void;
}
export const useFontStore = create<FontState>()(
@@ -13,6 +15,8 @@ export const useFontStore = create<FontState>()(
(set) => ({
font: 'lexend',
setFont: (font) => set({ font }),
uiScale: 1.0,
setUiScale: (uiScale) => set({ uiScale }),
}),
{ name: 'psysonic_font' }
)
+51 -2
View File
@@ -3,7 +3,7 @@ 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 } from '../api/subsonic';
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
@@ -76,6 +76,9 @@ interface PlayerState {
lastfmLovedCache: Record<string, boolean>;
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
/** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */
userRatingOverrides: Record<string, number>;
setUserRatingOverride: (id: string, rating: number) => void;
playRadio: (station: InternetRadioStation) => void;
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
@@ -161,6 +164,35 @@ let seekTarget: number | null = null;
// 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;
* threshold reached clears count and sets 1 if still unrated.
*/
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
if (!manual || !skippedTrack) return;
const id = skippedTrack.id;
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
if (!adv?.crossedThreshold) return;
const live = usePlayerStore.getState();
const fromQueue = live.queue.find(t => t.id === id);
const cur =
live.userRatingOverrides[id] ??
fromQueue?.userRating ??
skippedTrack.userRating ??
0;
if (cur >= 1) return;
setRating(id, 1)
.then(() => {
usePlayerStore.setState(s => ({
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 },
}));
})
.catch(() => {});
}
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
// Internet radio streams are played via a native <audio> element instead of
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
@@ -232,7 +264,7 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
console.error('Failed to sync play queue to server', err);
});
}, 1500);
}, 5000);
}
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
@@ -357,6 +389,9 @@ function handleAudioTrackSwitched(duration: number) {
isAudioPaused = false;
const store = usePlayerStore.getState();
if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
}
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
@@ -576,6 +611,19 @@ export const usePlayerStore = create<PlayerState>()(
lastfmLovedCache: {},
starredOverrides: {},
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
userRatingOverrides: {},
setUserRatingOverride: (id, rating) =>
set(s => {
const nextOverrides = { ...s.userRatingOverrides };
if (rating === 0) delete nextOverrides[id];
else nextOverrides[id] = rating;
return {
userRatingOverrides: nextOverrides,
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
};
}),
isQueueVisible: true,
isFullscreenOpen: false,
repeatMode: 'off',
@@ -887,6 +935,7 @@ export const usePlayerStore = create<PlayerState>()(
// ── next / previous ──────────────────────────────────────────────────────
next: (manual = true) => {
const { queue, queueIndex, repeatMode, currentTrack } = get();
applySkipStarOnManualNext(currentTrack, manual);
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue, manual);
+1
View File
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'playlists', visible: true },
{ id: 'mostPlayed', visible: true },
{ id: 'radio', visible: true },
{ id: 'folderBrowser', visible: false },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
+34
View File
@@ -6,6 +6,30 @@ type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowsto
interface ThemeState {
theme: Theme;
setTheme: (theme: Theme) => void;
enableThemeScheduler: boolean;
setEnableThemeScheduler: (v: boolean) => void;
themeDay: string;
setThemeDay: (v: string) => void;
themeNight: string;
setThemeNight: (v: string) => void;
timeDayStart: string;
setTimeDayStart: (v: string) => void;
timeNightStart: string;
setTimeNightStart: (v: string) => void;
}
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
if (!state.enableThemeScheduler) return state.theme;
const now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes();
const [dh, dm] = state.timeDayStart.split(':').map(Number);
const [nh, nm] = state.timeNightStart.split(':').map(Number);
const dayMins = dh * 60 + dm;
const nightMins = nh * 60 + nm;
const isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
export const useThemeStore = create<ThemeState>()(
@@ -13,6 +37,16 @@ export const useThemeStore = create<ThemeState>()(
(set) => ({
theme: 'mocha',
setTheme: (theme) => set({ theme }),
enableThemeScheduler: false,
setEnableThemeScheduler: (v) => set({ enableThemeScheduler: v }),
themeDay: 'latte',
setThemeDay: (v) => set({ themeDay: v }),
themeNight: 'mocha',
setThemeNight: (v) => set({ themeNight: v }),
timeDayStart: '07:00',
setTimeDayStart: (v) => set({ timeDayStart: v }),
timeNightStart: '19:00',
setTimeNightStart: (v) => set({ timeNightStart: v }),
}),
{
name: 'psysonic_theme',
+310 -1
View File
@@ -496,6 +496,76 @@
background: var(--accent);
}
/* ── Album card selection ── */
.album-card--selectable {
cursor: pointer;
}
.album-card--selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.album-card-select-check {
position: absolute;
top: 6px;
left: 6px;
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
border: 2px solid rgba(255,255,255,0.7);
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
pointer-events: none;
}
.album-card-select-check--on {
background: var(--accent);
border-color: var(--accent);
color: var(--ctp-crust);
}
/* ── Albums selection bar (portal) ── */
.albums-selection-bar {
position: fixed;
bottom: calc(var(--player-height) + 12px);
left: 50%;
transform: translateX(-50%);
z-index: 9000;
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 8px 14px;
box-shadow: var(--shadow-lg);
white-space: nowrap;
}
.albums-selection-count {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
padding-right: 4px;
border-right: 1px solid var(--border-subtle);
}
.albums-selection-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.albums-selection-action-btn {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
.album-card-info {
padding: var(--space-3) var(--space-4) var(--space-4);
@@ -519,6 +589,20 @@
margin-top: 2px;
}
.album-card-rating-row {
display: flex;
align-items: center;
gap: 5px;
margin-top: 4px;
}
.album-card-rating-stars {
font-size: 11px;
color: var(--accent);
letter-spacing: 1px;
line-height: 1;
}
/* ─ Live Search ─ */
.live-search {
position: relative;
@@ -997,7 +1081,50 @@
gap: var(--space-2);
color: var(--text-muted);
font-size: 13px;
margin: var(--space-2) 0 var(--space-4);
margin: var(--space-2) 0 var(--space-2);
}
.album-detail-entity-rating {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
margin-bottom: var(--space-4);
}
/* Inline with label: do not stretch to full row width */
.album-detail-entity-rating .star-rating {
width: auto;
justify-content: flex-start;
}
.album-detail-entity-rating-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.artist-detail-entity-rating {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
}
.artist-detail-entity-rating .star-rating {
width: auto;
justify-content: flex-start;
}
.artist-detail-entity-rating-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.album-detail-actions {
@@ -2257,6 +2384,18 @@
padding: var(--space-5);
}
.settings-hint {
font-size: 0.8rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
line-height: 1.45;
}
.settings-hint-info {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--text-secondary);
}
/* ─ Help Page ─ */
.help-list {
display: flex;
@@ -3508,6 +3647,15 @@
margin: var(--space-1) 0;
}
.context-menu-rating-row {
padding: 6px 12px;
}
.context-menu-rating-row .star-rating {
width: auto;
justify-content: flex-start;
}
/* ─ CSS Tooltips ─ */
/* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */
[data-tooltip] {
@@ -6417,12 +6565,37 @@
margin-bottom: 2.5rem;
}
.mp-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.mp-filter-btn {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
padding: 4px 10px;
opacity: 0.6;
}
.mp-filter-btn:hover { opacity: 1; }
.mp-filter-btn--active {
opacity: 1;
color: var(--accent);
border-color: var(--accent);
}
.mp-section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0;
margin: 0 0 1rem;
}
@@ -6578,3 +6751,139 @@
align-items: center;
gap: 0.5rem;
}
/* ─── UI Scale Slider ─── */
.ui-scale-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--border-subtle);
outline: none;
cursor: pointer;
}
.ui-scale-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.ui-scale-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent);
}
/* ── Folder Browser (Miller Columns) ──────────────────────────────────────── */
.folder-browser {
display: flex;
flex-direction: column;
padding: 0;
height: 100%;
}
.folder-browser-title {
padding: 1.5rem 1.5rem 0.75rem;
flex-shrink: 0;
}
.folder-browser-columns {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
/* height fallback for browsers that don't support flex: 1 correctly here */
height: calc(100vh - var(--player-height, 88px) - var(--titlebar-height, 0px) - 70px);
border-top: 1px solid var(--border-subtle);
}
.folder-browser-columns::-webkit-scrollbar {
height: 6px;
}
.folder-col {
flex: 1;
min-width: 200px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
border-right: 1px solid var(--border-subtle);
}
.folder-col-status {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
color: var(--text-muted);
font-size: 0.8rem;
padding: 1rem;
text-align: center;
}
.folder-col-error {
color: var(--danger, #f38ba8);
}
.folder-col-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.38rem 0.6rem 0.38rem 0.75rem;
background: none;
border: none;
border-radius: 0;
cursor: pointer;
color: var(--text-primary);
font-size: 0.8rem;
text-align: left;
transition: background 0.1s;
}
.folder-col-row:hover {
background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent));
}
.folder-col-row.selected {
background: var(--accent);
color: #fff;
}
.folder-col-row.selected .folder-col-chevron {
color: rgba(255, 255, 255, 0.7);
}
.folder-col-icon {
flex-shrink: 0;
opacity: 0.75;
display: flex;
align-items: center;
}
.folder-col-row.selected .folder-col-icon {
opacity: 1;
}
.folder-col-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-col-chevron {
flex-shrink: 0;
color: var(--text-muted);
margin-left: auto;
}
+129
View File
@@ -27,6 +27,115 @@
background: var(--bg-app);
}
/* ─── Custom title bar (Linux only — decorations: false) ─── */
:root {
--titlebar-height: 32px;
}
.app-shell[data-titlebar] {
grid-template-rows: var(--titlebar-height) 1fr var(--player-height);
grid-template-areas:
"titlebar titlebar titlebar"
"sidebar main queue"
"player player player";
}
/* Resize grips — replace the native GTK grips lost when decorations: false */
.app-shell[data-titlebar]::before,
.app-shell[data-titlebar]::after {
content: '';
position: fixed;
bottom: 0;
width: 14px;
height: 14px;
pointer-events: none;
background-image: radial-gradient(circle, var(--text-muted) 1.5px, transparent 1.5px);
background-size: 4px 4px;
opacity: 0.35;
z-index: 9999;
}
.app-shell[data-titlebar]::before { left: 0; }
.app-shell[data-titlebar]::after { right: 0; }
.titlebar {
grid-area: titlebar;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border-subtle);
padding: 0 6px 0 12px;
height: var(--titlebar-height);
user-select: none;
}
.titlebar-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
pointer-events: none;
flex: 0 0 auto;
}
.titlebar-track {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
max-width: 40%;
pointer-events: none;
}
.titlebar-track-state {
font-size: 9px;
color: var(--accent);
flex-shrink: 0;
}
.titlebar-track-text {
font-size: 12px;
color: var(--text-secondary);
max-width: 100%;
}
.titlebar-controls {
display: flex;
gap: 2px;
flex: 0 0 auto;
}
.titlebar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.titlebar-btn-close:hover {
background: var(--danger);
color: #fff;
}
/* Resizer handles must start below the titlebar */
.app-shell[data-titlebar] .resizer {
top: var(--titlebar-height);
}
/* ─── Resizer Handles ─── */
.resizer {
position: absolute;
@@ -842,6 +951,17 @@
margin-top: 2px;
}
.player-track-rating {
margin-top: 4px;
width: auto;
justify-content: flex-start;
}
.player-track-rating .star {
font-size: 11px;
padding: 0 1px;
}
/* ── Marquee (PlayerBar track name / artist) ── */
.marquee-wrap {
overflow: hidden;
@@ -1460,6 +1580,15 @@
max-width: 100%;
}
.app-shell[data-mobile][data-titlebar] {
grid-template-rows: var(--titlebar-height) 1fr auto auto;
grid-template-areas:
"titlebar"
"main"
"bottomnav"
"player";
}
/* Hide the sidebar collapse button on mobile */
.app-shell[data-mobile] .collapse-btn {
display: none;
+78
View File
@@ -3800,6 +3800,84 @@ select.input.input:focus {
cursor: pointer;
}
/* While clearing: no yellow hover until pointer leaves or next click */
.star-rating--suppress-hover .star:hover {
color: var(--ctp-overlay1);
}
.star-rating--disabled {
opacity: 0.55;
}
.star-rating--disabled .star:hover {
color: inherit;
cursor: not-allowed;
}
.star-rating .star {
transform-origin: center center;
}
.star-rating .star.star--locked {
opacity: 0.4;
cursor: default;
}
.star-rating .star.star--locked:hover {
color: var(--ctp-overlay1);
cursor: default;
}
@keyframes star-rating-star-pulse {
0% {
transform: scale(1);
opacity: 1;
}
45% {
transform: scale(1.3);
opacity: 0.88;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.star-rating .star.star--pulse {
animation: star-rating-star-pulse 0.38s ease-out;
}
@keyframes star-rating-star-clear-shrink {
0% {
transform: scale(1);
opacity: 1;
}
55% {
transform: scale(0.58);
opacity: 0.28;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.star-rating .star.star--clear-shrink {
animation: star-rating-star-clear-shrink 0.44s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.star-rating .star.star--pulse,
.star-rating .star.star--clear-shrink {
animation: none;
}
}
/* ─── Animations ─── */
@keyframes fadeIn {
from {
+1
View File
@@ -14,6 +14,7 @@ const BACKUP_KEYS = [
'psysonic-eq',
'psysonic_global_shortcuts',
'psysonic-player',
'psysonic_home',
];
export async function exportBackup(): Promise<string | null> {
+2
View File
@@ -127,6 +127,8 @@ const MIN_CONTRAST = 4.5;
*/
export function extractCoverColors(imageUrl: string): Promise<CoverColors> {
if (!imageUrl) return Promise.resolve({ accent: '' });
// Logo fallback has no meaningful color — skip extraction and use theme accent
if (imageUrl.includes('logo-psysonic')) return Promise.resolve({ accent: '' });
return new Promise(resolve => {
const img = new Image();
+227
View File
@@ -0,0 +1,227 @@
import {
getRandomSongs,
prefetchAlbumUserRatings,
prefetchArtistUserRatings,
type SubsonicAlbum,
type SubsonicSong,
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
/** Target list size for Random Mix after rating filter. */
export const RANDOM_MIX_TARGET_SIZE = 50;
const RANDOM_MIX_BATCH_SIZE = 50;
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme). */
const RANDOM_MIX_MAX_BATCHES = 40;
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set). */
const RANDOM_MIX_MAX_DUP_STREAK = 6;
export interface MixMinRatingsConfig {
enabled: boolean;
minSong: number;
minAlbum: number;
minArtist: number;
}
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
const s = useAuthStore.getState();
return {
enabled: s.mixMinRatingFilterEnabled,
minSong: s.mixMinRatingSong,
minAlbum: s.mixMinRatingAlbum,
minArtist: s.mixMinRatingArtist,
};
}
function numRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return undefined;
return n;
}
function ratingFromArtistRefs(
list: Array<{ id?: string; userRating?: unknown }> | undefined,
preferId?: string,
): number | undefined {
if (!list?.length) return undefined;
if (preferId) {
const m = list.find(a => a.id === preferId);
const r = numRating(m?.userRating);
if (r !== undefined) return r;
}
for (const a of list) {
const r = numRating(a.userRating);
if (r !== undefined) return r;
}
return undefined;
}
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
const d = numRating(song.artistUserRating);
if (d !== undefined) return d;
const fromArtists = ratingFromArtistRefs(song.artists, song.artistId);
if (fromArtists !== undefined) return fromArtists;
return ratingFromArtistRefs(song.albumArtists, song.artistId);
}
/** Song-level album (parent) rating when the server puts it on the child payload. */
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
return numRating(song.albumUserRating);
}
/**
* Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
* chosen threshold (inclusive). `0` / missing = unrated, never excluded.
*/
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
if (!c.enabled) return true;
if (c.minSong > 0) {
const r = numRating(song.userRating);
if (r !== undefined && r > 0 && r <= c.minSong) return false;
}
if (c.minAlbum > 0) {
const r = effectiveAlbumRatingOnSong(song);
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
}
if (c.minArtist > 0) {
const r = effectiveArtistRatingForFilter(song);
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
}
return true;
}
export interface MixAlbumFilterExtra {
/** From `getArtist` when list payloads omit artist rating. */
artistUserRating?: number;
/** From `getAlbum` when list payloads omit album `userRating`. */
albumUserRating?: number;
}
/**
* Random album lists: album `userRating` when present; optional extra from entity fetches.
* Song axis is not on this payload. `0` / missing = unrated, keep.
*/
export function passesMixMinRatingsForAlbum(
album: SubsonicAlbum,
c: MixMinRatingsConfig,
extra?: MixAlbumFilterExtra,
): boolean {
if (!c.enabled) return true;
if (c.minAlbum > 0) {
const r = numRating(album.userRating ?? extra?.albumUserRating);
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
}
if (c.minArtist > 0) {
const r = numRating(extra?.artistUserRating);
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
}
return true;
}
/**
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
*/
export async function filterAlbumsByMixRatings(
albums: SubsonicAlbum[],
c: MixMinRatingsConfig,
): Promise<SubsonicAlbum[]> {
if (!c.enabled) return albums;
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
const needArtist = c.minArtist > 0;
const needAlbum = c.minAlbum > 0;
let byArtist = new Map<string, number>();
let byAlbum = new Map<string, number>();
if (needArtist) {
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
byArtist = await prefetchArtistUserRatings(ids);
}
if (needAlbum) {
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
}
return albums.filter(a =>
passesMixMinRatingsForAlbum(a, c, {
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
albumUserRating: byAlbum.get(a.id),
}),
);
}
/**
* Merge `getArtist` / `getAlbum` ratings into songs before `passesMixMinRatings` when list payloads omit them.
*/
export async function enrichSongsForMixRatingFilter(
songs: SubsonicSong[],
c: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
const artistIds =
c.minArtist > 0
? [...new Set(songs.filter(s => s.artistUserRating === undefined && effectiveArtistRatingForFilter(s) === undefined && s.artistId).map(s => s.artistId!))]
: [];
const albumIds =
c.minAlbum > 0
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
: [];
const [byArtist, byAlbum] = await Promise.all([
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
]);
if (!byArtist.size && !byAlbum.size) return songs;
return songs.map(s => ({
...s,
...(s.artistUserRating === undefined &&
s.artistId &&
byArtist.has(s.artistId) && { artistUserRating: byArtist.get(s.artistId)! }),
...(s.albumUserRating === undefined &&
s.albumId &&
byAlbum.has(s.albumId) && { albumUserRating: byAlbum.get(s.albumId)! }),
}));
}
/**
* Loads random songs in batches until `RANDOM_MIX_TARGET_SIZE` pass `passesMixMinRatings` (after enrich),
* or limits are hit. When the mix rating filter is off, a single batch is used.
*/
export async function fetchRandomMixSongsUntilFull(
c: MixMinRatingsConfig,
opts?: { genre?: string; timeout?: number },
): Promise<SubsonicSong[]> {
const timeout = opts?.timeout ?? 15000;
const genre = opts?.genre;
if (!c.enabled) {
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
return raw.slice(0, RANDOM_MIX_TARGET_SIZE);
}
const out: SubsonicSong[] = [];
const outIds = new Set<string>();
const seenFromApi = new Set<string>();
let dupStreak = 0;
for (let b = 0; b < RANDOM_MIX_MAX_BATCHES && out.length < RANDOM_MIX_TARGET_SIZE; b++) {
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
if (!raw.length) break;
const novel = raw.filter(s => !seenFromApi.has(s.id));
for (const s of raw) seenFromApi.add(s.id);
if (!novel.length) {
if (++dupStreak >= RANDOM_MIX_MAX_DUP_STREAK) break;
continue;
}
dupStreak = 0;
const enriched = await enrichSongsForMixRatingFilter(novel, c);
for (const s of enriched) {
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
outIds.add(s.id);
out.push(s);
if (out.length >= RANDOM_MIX_TARGET_SIZE) break;
}
}
return out;
}
+2
View File
@@ -0,0 +1,2 @@
/** True when running on Linux (WebKitGTK). Used to show the custom title bar. */
export const IS_LINUX = navigator.platform.toLowerCase().includes('linux');
+6 -1
View File
@@ -24,8 +24,13 @@ function loadPrefs(
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).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: { ...defaultWidths, ...(parsed.widths ?? {}) },
widths,
visible,
};
} catch {