Compare commits

...

27 Commits

Author SHA1 Message Date
Psychotoxical c67d606f89 feat: v1.24.0 — Playlist Management, native sample rate playback
- Full playlist feature: overview grid, detail page with hero collage,
  tracklist DnD, song search, suggestions, context menu submenu
- Audio: disable all app-level resampling — every track plays at its
  native sample rate (target_rate always 0 in audio_play + chain_next)
- Fix: playlist hero bg flicker (memoize buildCoverArtUrl calls)
- Fix: input focus double-border (search-input → .input class)
- Polish: redesigned playlist search panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:14:15 +02:00
Psychotoxical 662cc94ca8 fix: disable forced resampling to 44100 Hz on first track
current_sample_rate was initialized to 44100, causing every track to be
resampled down to 44.1 kHz. Setting it to 0 disables resampling until
the actual track rate is known, so songs play at their native sample rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:01:58 +02:00
Psychotoxical 1eacaf678c Update README.md 2026-03-30 23:16:47 +02:00
Psychotoxical 4a8fb64c66 Update README.md 2026-03-30 23:16:04 +02:00
Psychotoxical 43c656dfc3 feat: v1.23.0 — Advanced Search, Genre Mix overhaul, Playlist append, Contributors table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:56:59 +02:00
Psychotoxical 74b519f9f5 feat: Replay Gain support — songToTrack() + audio_update_replay_gain
Integrates PR #9 by @trbn1: replay gain was not applying because track
objects were built manually without replayGainTrackDb/replayGainAlbumDb/
replayGainPeak fields. All track construction sites now use songToTrack().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:46 +02:00
Psychotoxical 3d03b8d5a1 merge: PR #9 — Replay Gain fix (trbn1) + conflict resolution
Merges songToTrack() helper and audio_update_replay_gain Tauri command
from @trbn1 without losing v1.22.0 DnD/queue-management work:

- All track construction sites use songToTrack() for correct replay gain
- psy-drag system (mouse-event DnD) preserved across all components
- enqueueAt() position-aware insertion preserved in QueuePanel
- updatePlaylist / smart-save / active-playlist tracking preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:29 +02:00
Psychotoxical d6f6e6466c feat: v1.22.0 — Queue Management, DnD Overhaul, Seek & Waveform Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:14:59 +02:00
trbn 95cdbc7fc7 fix: replay gain not applying to tracks
Replay gain was not working because track objects were created manually without
including replay gain metadata from the Subsonic API response.

Changes:
- Add songToTrack() helper function to properly map SubsonicSong to Track with
  replayGainTrackDb, replayGainAlbumDb, and replayGainPeak fields
- Add audio_update_replay_gain Tauri command for dynamic volume recalculation
  when replay gain settings change mid-playback
- Add updateReplayGainForCurrentTrack() to recalculate volume when toggling
  replay gain setting
- Fetch fresh track data on cold resume (app relaunch) to ensure replay gain
  values are current from server
- Update all files that create track objects to use songToTrack()

Fixes issue where toggling replay gain ON/OFF or changing between track/album
mode had no effect on currently playing or newly played tracks.
2026-03-30 19:11:33 +02:00
Psychotoxical 42863877f6 fix: close seek-flash race window after debounce expires
PR #7 blocked stale Rust progress ticks during the 100 ms debounce
window but lifted the guard exactly when invoke('audio_seek') went out.
The engine can still emit 1–2 ticks with the old position before the
seek takes effect.  Add seekTarget: once the debounce fires, record the
requested time and keep ignoring progress until current_time is within
2 s of that target, then clear it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:28:48 +02:00
Psychotoxical 7ed0fa4914 Merge branch 'pr-8' into test/pr-7-8 2026-03-30 17:24:18 +02:00
Joshua Bassett 4f8e7d7bc7 fix: stabilize waveform seekbar width when player time changes
Add min-width to .player-time elements so they don't resize when the
displayed time string changes length (e.g. "9:59" → "10:00"). Not all
fonts support the tabular-nums font-variant, so without a min-width the
time elements can change size and cause the waveform seekbar to
continuously resize as playback progresses.

Also align the elapsed time to the right and duration to the left so
they sit flush against the waveform edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:17:50 +11:00
Joshua Bassett bb56269cd1 fix: prevent waveform seekbar flashing back to old position on seek
When clicking/dragging the waveform seekbar, the playback position would
briefly flash back to the previous position before snapping to the new
one.

The seek function optimistically updates the store with the target
position immediately but debounces the actual audio_seek IPC call by
100ms. During that window, audio:progress events from the Rust backend
continue to arrive carrying the old playback position, overwriting the
optimistic update and causing the visual flash.

The fix skips incoming audio:progress events while a seek debounce is
pending, since the store already holds the correct target position. Once
the debounce fires and audio_seek is sent to the backend, progress
events resume normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:55 +11:00
Psychotoxical 29a4363dca feat: v1.21.0 — What's New Modal, 3 New Themes (Beta), Artist Column, Powerslave Blue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:48:40 +02:00
Psychotoxical e1d27798eb feat: v1.20.0 — FLAC Seek Fix, Genres, Genre Filter, Chinese, W10 Theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:04:01 +02:00
Psychotoxical b35539d3cf Merge pull request #3 from jiezhuo/feature/chinese_translations
Add Chinese language
2026-03-29 16:32:46 +02:00
jiezhuo a1b3022140 Add Chinese language 2026-03-28 22:04:13 +08:00
Psychotoxical b6fb66c46d feat: v1.19.0 — NSIS Installer, Tray Removed, Storage Warning, Theme Polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:56:35 +01:00
Psychotoxical 936e548f40 feat: v1.18.0 — Offline Mode (Beta), MPRIS Seek, 2 New Themes, Perf Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:17:09 +01:00
Psychotoxical b67c198227 fix: player bar CSS + Windows media keys HWND (v1.17.2)
- layout: overflow:hidden on .app-shell + min-height:0 on sidebar/main/queue
  prevents player bar from being pushed off-screen when window is resized
  below OS minHeight constraint (ignored by some Linux WMs)
- lib.rs: pass main window HWND to souvlaki PlatformConfig on Windows so
  SMTC hooks into the existing Win32 message loop instead of creating its
  own — fixes media keys on Windows without crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:15:25 +01:00
Psychotoxical 65a828e3fa fix: disable souvlaki on Windows to fix startup crash (v1.17.1)
SMTC init via souvlaki requires a valid HWND + COM message loop, which
are not available in setup(). Guarded behind #[cfg(not(target_os = "windows"))].
mpris_set_metadata / mpris_set_playback no-op on Windows via the None branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:02:09 +01:00
Psychotoxical 6bdd6f3a59 feat: v1.17.0 — Media Keys, 3 New Themes, Perf Fixes, Contrast Audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:30:13 +01:00
Psychotoxical d62bffd082 feat: v1.16.0 — 15 New Themes, W98 Overhaul, Aqua Quartz Polish
New themes: Aqua Quartz (Mac OS X Aqua, skeuomorphic), Spider-Tech,
T-800, B-Runner, Hill Valley 85, TetraStack, Turtle Power, Insta,
ReadIt, The Book (new Social Media group), W3.1, Jayfin (Jellyfin).

W98 rebuilt from scratch: authentic #d4d0c8 warm-gray, full 4-layer
3D bevel on all panels/buttons (raised/sunken on press), title bar
gradient on song name, navy progress fill, 16px styled scrollbar.

Aqua Quartz: all button variants now jelly-styled, blue Source List
sidebar with white pill nav, aluminium pinstripe background.

Theme Picker: groups and themes sorted alphabetically, Mediaplayer
group renamed, Pandora/Order of the Phoenix/Imperial Sith removed.

Fix: AlbumDetail genre propagation, W98 accordion active state,
Aqua Quartz sidebar labels, W98 connection indicator contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:54:57 +01:00
Psychotoxical ff706104ab fix: propagate genre in AlbumDetail track constructions
Play All, Enqueue All, and single-song play in AlbumDetail were
building Track objects without genre/starred fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:57:17 +01:00
Psychotoxical 0abef4b266 docs: update README screenshot 2026-03-23 19:52:18 +01:00
Psychotoxical 3effad0830 feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:47:32 +01:00
Psychotoxical 361e9cfdb3 feat: v1.14.0 — Critical Buffer Fix, Gapless/Crossfade stable, UX polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:00:55 +01:00
71 changed files with 16385 additions and 3585 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: ''
args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
+303
View File
@@ -5,6 +5,309 @@ 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.24.0] - 2026-03-31
### Added
- **Playlist Management** *(requested by [@adirav02](https://github.com/adirav02))*: Full playlist management feature:
- **Playlists overview page** (`/playlists`): card grid showing all server playlists with cover collage, song count and duration. Inline "New Playlist" creation (Enter to confirm, Escape to cancel). Two-click delete confirmation directly on the card.
- **Playlist detail page** (`/playlists/:id`): hero area with 2×2 album cover collage and blurred background (matching Album Detail style), full tracklist with drag-and-drop reordering, star ratings, codec labels, per-row delete button, and context menu.
- **Song search**: "Add Songs" button opens an inline search panel with debounced server search, thumbnail, artist · album info, and a round add button (accent on hover). Duplicate songs already in the playlist are filtered from results.
- **Suggestions**: "Suggested Songs" section below the tracklist loads similar songs via `getSimilarSongs2` based on the first artist in the playlist. Refresh button to load a new batch. Same tracklist layout as search results.
- **Context menu — Add to Playlist**: "Add to Playlist" submenu available on all song/album/queue-item context menus. Playlists sorted by most recently used. "New Playlist" inline create at the top of the submenu. Submenu flips left when near the right viewport edge.
- **Sidebar**: Playlists navigation entry added between Favorites and Statistics.
- **Recently used playlist tracking**: `playlistStore` (persisted) tracks the last 50 used playlist IDs for the context menu sort order.
### Fixed
- **Resampling — first track played at native sample rate** *(reported by [@sorensiimSalling](https://github.com/sorensiimSalling))*: `current_sample_rate` was initialized to `44100`, causing every track to be resampled down to 44.1 kHz on playback start. Initializing to `0` disables resampling until the actual track rate is known.
- **Resampling — no application-level resampling for any track**: `target_rate` in `audio_play` and `audio_chain_next` is now always `0`. Previously, tracks after the first were resampled to match the first track's sample rate. Rodio handles conversion to the output device rate internally; every track now plays at its native sample rate.
- **Playlist hero background flickering**: The blurred hero background in Playlist Detail flickered on every render because `buildCoverArtUrl()` generates a new random salt on every call, causing `useCachedUrl` to re-trigger in a loop. The fetch URL and cache key are now `useMemo`-stabilised.
- **Input focus double border**: The playlist name and song search inputs used a `search-input` class that had no CSS definition, falling back to browser defaults. The global `:focus-visible` rule then added a second outline on top of the browser's own focus ring. Switched to the `.input` class which sets `outline: none` and uses `border-color` + glow on focus.
### Changed
- **Playlist search panel**: Redesigned with `surface-2` background, `radius-lg`, slide-down open animation, 36 px thumbnails, artist · album subtitle line, and a round icon add-button (accent colour on hover) replacing the generic `btn-surface` button.
---
## [1.23.0] - 2026-03-30
### Added
- **Advanced Search**: New dedicated page (`/search/advanced`) reachable via the filter icon in the search bar. Supports free-text search combined with genre filter (dropdown from server), year range (from/to), and result-type toggle (All / Artists / Albums / Songs). Search logic: text query uses `search3` with client-side genre/year filtering; genre-only uses `getAlbumsByGenre` + random songs from that genre; year-only uses `getAlbumList(byYear)`. Results show in the standard ArtistRow / AlbumRow / tracklist layout with drag-to-queue and context menu support.
- **Genre Mix — Server-native genres**: The Genre Mix panel in Random Mix now shows the top 20 genres from the server sorted by song count, instead of hardcoded keyword-based "Super Genre" groups. Only genres with at least one song and no audiobook keywords are shown. Clicking a badge fetches up to 50 random songs from exactly that genre.
- **Genre Mix — Shuffle button**: A ↺ button appears when the server has more than 20 genres. Clicking it picks a fresh random selection of 20 from all available genres, replacing the current badges without triggering a search.
- **Favorites — Play All**: "Play All" button (primary style) added next to "Add all to queue" in the Favorites → Songs section. Starts playback immediately from the first favorited song.
- **Playlist Load — Append mode**: The playlist load modal now has two action buttons per playlist: ▶ replaces the queue and starts playback (previous behavior), ≡+ appends all tracks to the existing queue without interrupting playback.
### Fixed
- **Replay Gain** *(contributed by [@trbn1](https://github.com/trbn1))*: Replay Gain metadata (track gain, album gain, peaks) is now correctly propagated to the audio engine across all track-construction sites via the new `songToTrack()` helper. Previously tracks built inline missed the `replayGain` field, causing the engine to apply 0 dB gain regardless of tags.
### Changed
- **Genre Mix description**: Panel subtitle updated to explain that badges represent the top 20 genres by song count and that clicking loads a random mix from that genre.
- **Random Mix — Filter panel**: Added a short descriptive hint below the "Filters" heading explaining that genre tags and artist names in the tracklist are clickable to add them to the blacklist.
- **Playlist Load modal**: Width increased from 400 px to 560 px (90 vw cap) so long playlist names are readable without truncation.
- **Settings — Contributors**: Contributors section is now a collapsible table. Each entry shows the contributor's GitHub avatar, `@username` (linked to their profile), a version badge, and a bullet list of their specific contributions. [@trbn1](https://github.com/trbn1) added for Replay Gain fix (PR #9).
### Theme Fixes
- **Powerslave**: Album card play button no longer flickers between gradient and flat accent color on hover — explicit `:hover` gradient override added. Sidebar stripe pattern replaced with soft radial-gradient cloud wisps.
---
## [1.22.0] - 2026-03-30
### Added
- **Queue — Active Playlist Tracking** *(Beta)* ⚠️: The queue now remembers which playlist was last loaded or saved. The playlist name appears as a subtitle below the queue title. The save button smart-saves: if an active playlist is set, it updates that playlist directly without opening a modal. If no playlist is active, the save modal opens as before.
- **Queue — Themed Delete Confirmation** *(Beta)* ⚠️: Deleting a playlist now shows a styled in-app confirmation dialog matching the current theme, replacing the unstyled native browser `confirm()` dialog.
- **Queue — Load Modal Live Filter** *(Beta)* ⚠️: The playlist load modal now has a live filter input at the top — typing narrows the playlist list in real time.
- **Drag & Drop — Precise Insertion** *(Beta)* ⚠️: Songs and albums dragged into the queue can now be dropped at any position between existing items. A blue insertion line shows exactly where the track will land. Previously all drops appended to the end of the queue.
- **Drag & Drop — Slim Ghost** *(Beta)* ⚠️: The drag ghost is now a compact single-line chip (cover thumbnail + title) instead of the full album card or track row. Consistent for both song and album drags.
### Fixed
- **Seek flash after debounce** *(contributed by [@nullobject](https://github.com/nullobject))*: After a seek the waveform briefly flashed back to the pre-seek position when the Rust `audio:progress` event arrived before the seek completed. A `seekTarget` guard now blocks stale progress ticks until the engine catches up.
- **Waveform seekbar jitter** *(contributed by [@nullobject](https://github.com/nullobject))*: The seekbar width changed on every progress tick because player time updates caused the waveform canvas container to reflow. The canvas now has an explicit stable width so time label changes no longer affect its layout.
- **Drag & Drop — text selection and grid auto-scroll during drag**: Dragging album cards or track rows caused the browser to begin a text selection and auto-scroll grid rows horizontally. All drag `onMouseDown` handlers now call `preventDefault()` and the DragDropContext uses `{ passive: false }` to suppress selection during mouse moves.
- **Drag & Drop — forbidden cursor on KDE Plasma**: Replaced the HTML5 `dragstart`/`dragend` system with a pure mouse-event DnD pipeline (`DragDropContext`). The WebKitGTK forbidden-cursor artefact on KDE Plasma no longer appears during drags.
### Changed
- **Settings — Contributors**: [@nullobject](https://github.com/nullobject) added for seek & waveform fixes.
### Theme Fixes
- **Powerslave**: Connection indicators (Last.fm / Server name) dimmed to match sidebar tone. Back button in album details now white on dark overlay. Tech strip (codec/bitrate) in queue uses dark Nile-blue background instead of sandstone. Artist name in album hero changed to Nile-blue `#050E19`.
- **North Park**: Back button in album details now visible (was dark brown on dark overlay).
- **Dark Side of the Moon**: Album detail year/genre/info brightened from `#555555` to `#888888`. Connection indicators brightened for legibility on near-black sidebar.
---
## [1.21.0] - 2026-03-29
### Added
- **What's New modal**: On first launch after an update, a changelog popup appears showing the current version's release notes. Can be permanently dismissed via checkbox, or re-enabled in Settings → About.
- **New theme category — Famous Albums**: A dedicated group for album-art-inspired themes.
- **Theme — Dark Side of the Moon (inspired)** *(Famous Albums)* ⚠️ **Beta**: Void-black everywhere, the iconic prism spectrum rainbow as a 2 px top border on the player bar, spectrum-violet accent `#9B30FF`, white track name (the input light beam).
- **Theme — Powerslave (inspired)** *(Famous Albums)* ⚠️ **Beta**: Sun-bleached sandstone main area, deep Nile-sky blue sidebar and player bar, pharaoh gold accent `#C8960C`. Bluegold duality mirrors the album artwork's vivid azure sky against the Egyptian temple gold.
- **Theme — North Park** *(Series)* ⚠️ **Beta**: South Park-inspired. Construction-paper cream main area, Colorado mountain-blue `#1B3D6E` sidebar, Kenny orange `#FF8C00` accent, flat no-gradient buttons.
### Changed
- **AlbumTrackList — artist column always visible**: The artist column is now shown on all albums, not only Various Artists compilations. Useful for albums with guest artists or featuring credits where track-level artist differs from the album artist.
- **Tracklist column widths — more flexible**: Title and artist columns now use `minmax` fr units (`1.5fr` / `1fr`) instead of fixed sizes, so the artist column moves naturally closer to the title on wide viewports and never clips on narrow ones.
### Fixed
- **Settings — changelog toggle alignment**: The "Show What's New on update" toggle was rendering below its label instead of beside it.
---
## [1.20.0] - 2026-03-29
### Added
- **Chinese language (zh)**: Full UI translation contributed by [@jiezhuo](https://github.com/jiezhuo). Language can be selected in Settings → General.
- **Genres page** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: New page (sidebar: Tags icon) showing all server genres as coloured cards — icon watermark, genre name, album count. Cards are sorted by album count descending and deterministically colour-coded from the Catppuccin palette. Clicking a card opens the album list for that genre. Navigating back restores the previous scroll position.
- **Genre filter on Albums, New Releases, Random Albums** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: A multi-select genre combobox in the page header lets you filter any of these views to one or more genres. Chips show selected genres; backspace removes the last one; clicking outside collapses the filter automatically when nothing is selected. In filter mode, results are fetched in parallel across all selected genres and deduped client-side.
- **Settings — Contributors**: A new "Contributors" row in the About section credits community translators.
### Changed
- **Theme — W10** *(Operating Systems)*: New Windows 10 Fluent Design light theme. Clean white content area, flat light-grey `#F3F3F3` navigation pane, near-black `#1C1C1C` taskbar player bar with a Windows-blue `#0078D4` accent stripe, flat buttons without gradients (4 px radius). Sharp, unmistakably W10 — distinct from the glass-era W7/Vista and the rounded-corner W11.
- **ThemePicker — Windows themes sorted by release year**: W3.1 → W98 → WXP → Wista → W7 → W10 → W11.
- **Playlists page — removed**: The dedicated Playlists page has been removed. Playlists remain fully accessible via the Queue panel (Save / Load buttons in the toolbar).
### Fixed
- **FLAC seeking** *(Rust audio engine)*: `rodio`'s internal `ReadSeekSource` hardcodes `byte_len() → None`, which caused the symphonia FLAC demuxer to reject all seek attempts (it validates seek byte offsets against the total stream length). Replaced `rodio::Decoder` with a direct symphonia pipeline (`SizedDecoder`) that wraps the audio bytes in a `SizedCursorSource` providing the correct `byte_len()`. FLAC seeking now works regardless of whether the file has an embedded SEEKTABLE.
- **Genre missing in Queue meta box when playing from album card**: `playAlbum()` (used by the play button on all album cards) mapped song-level genre only — which Navidrome does not always return per song. Now falls back to the album-level genre from `getAlbum`. Same fallback applied to all three play/enqueue handlers in `AlbumDetail`.
- **Logo gradient CSS variables**: Sidebar logo gradient now uses `--logo-color-start` / `--logo-color-end` with fallbacks, allowing themes with dark sidebars to override the gradient colours.
---
## [1.19.0] - 2026-03-27
### Added
- **Offline storage full warning**: When caching an album would exceed the configured storage limit, a dismissible warning banner appears directly on the album page with quick links to the Offline Library and Settings.
- **Offline Mode — Help section**: New section in the Help page covering cache setup, playback, and troubleshooting for offline use.
### Changed
- **Windows installer — NSIS**: Switched from WiX/MSI to NSIS (`currentUser` install mode). Upgrades install in-place without requiring an uninstall first.
- **Tray icon — removed**: The system tray icon and its menu have been removed. Media keys and OS media controls (added in v1.17.0) make the tray redundant. The "Minimize to tray" setting has been removed accordingly. The app now always exits cleanly on window close.
- **Settings — cache label**: "Max. Image Cache Size" renamed to "Max. Storage Size" to reflect that the limit now covers both image cache and offline tracks.
- **Cover art — fade-in on load**: `CachedImage` now fades album art in (150 ms) instead of popping in abruptly. The image starts transparent and becomes visible once fully loaded, preventing layout flicker on slow connections.
- **Scrollbar auto-hide**: Scrollbar thumbs are hidden when content is not being scrolled and fade in on hover or while actively scrolling. System-style themes (W98, Muma Jukebox, Luna Teal, W3.1, DOS) retain always-visible scrollbars.
- **Help page — two-column layout**: Sections now flow in CSS columns (masonry layout) instead of a rigid two-column grid, making better use of available space.
- **Theme picker — preview corrections**: Updated colour swatches for T-800 (red accent, was cyan), WnAmp (yellow accent, was green), TetraStack (darker navy background), NightCity 2077 (darker blue-tinted background).
- **Theme overhaul — Grand Theft Audio, NightCity 2077**: Detailed per-element styling added — active queue item, hover states, track rows, artist/playlist rows, settings tabs, connection indicators, and more. Both themes are now fully consistent across all UI sections.
- **Theme refinements — Lambda 17, T-800, TetraStack, Muma Jukebox**: Targeted fixes for connection indicators, hover colours, active states, and contrast throughout.
### Fixed
- **AlbumDetail — hero background flicker on hover**: Moving the mouse over songs in the track list caused the blurred hero background to reload on every hover. Moving `hoveredSongId` state into `AlbumTrackList` prevents the parent from re-rendering.
- **AlbumDetail — context menu loses row highlight**: Right-clicking a song caused the hover highlight to disappear. The row now stays highlighted while its context menu is open (`.context-active` pattern — consistent with Queue and Random Mix).
- **Muma Jukebox — hero readability**: The "Album" chip and meta info text below the artist name had insufficient contrast. Both are now legible.
- **Muma Jukebox — waveform colours**: Waveform now uses orange (played) and cyan (buffered) to match the theme's colour scheme.
---
## [1.18.0] - 2026-03-27
### Added
- **Offline Mode *(Beta — tested on CachyOS only)***: Albums can now be cached for offline playback via the new "Cache Offline" button in the album header. Cached albums are accessible in the new **Offline Library** page. On launch without internet, the app automatically navigates there if cached content is available — no blocking overlay. A slim non-blocking banner shows while in offline mode. Offline tracks are removed when clearing the cache.
- **Settings — Cache section improvements**: Live usage display (image cache + offline tracks). Adjustable limit now goes up to 5 GB. When the limit is reached, the oldest image cache entries are evicted automatically (offline albums are not auto-removed). "Clear Cache" button with confirmation removes both image cache and all offline albums.
- **MPRIS — Seek support**: The Plasma (and other MPRIS2-compatible) seekbar now works correctly. Seek and SetPosition events from the OS are forwarded to the audio engine. Position is synced every 500 ms while playing so the OS overlay stays accurate.
- **Lyrics caching**: Fetched lyrics are cached in memory for the session. Switching between Queue and Lyrics tabs no longer re-fetches from lrclib.net.
- **2 New Themes** *(Movies)*:
- **Barb & Ken** — Barbie dreamhouse universe. Deep magenta dark, polka-dot sidebar, glitter shimmer animation on track name, Ken powder blue for artist name and volume slider.
- **Toy Tale** — Toy Story. Dark warm toy-chest brown main, Andy's iconic cloud-wallpaper sky-blue sidebar, Woody sheriff-star gold track name, Buzz Lightyear purple for active queue item and volume slider.
### Changed
- **Hero carousel — background crossfade**: The blurred background no longer flickers when switching albums. The last resolved URL is held until the new one is ready, so the old background stays visible until the new one loads.
- **AlbumDetail — Download hint**: Removed the inline hint text from the album header. The explanation (server zips first — may take a moment) is now in the Help FAQ.
### Fixed
- **Performance — Home page scroll**: `AlbumCard` subscribed to two large Zustand record objects (`tracks`, `albums`) per card — 96+ selector calls across a typical home page. Replaced with a single boolean selector per card. Added `React.memo` to prevent re-renders when parent rows reload.
- **Middle Earth theme — active queue item contrast**: Track title was invisible (dark text on dark background). Fixed to bright gold. Tech info bar text also corrected.
---
## [1.17.2] - 2026-03-26
### Fixed
- **Player bar disappears when window is resized small**: On Linux (and some Windows configurations), the window manager ignores the `minHeight` constraint, allowing the window to be dragged smaller than intended. The CSS grid's `1fr` row has an implicit `min-height: auto`, meaning it refuses to shrink below the min-content height of the sidebar/main/queue children — this pushed the total grid height beyond `100vh` and scrolled the player bar out of view. Fixed by adding `min-height: 0` to `.sidebar`, `.main-content`, and `.queue-panel`, and `overflow: hidden` to `.app-shell` as a safety net.
- **Media keys on Windows (SMTC)**: souvlaki's Windows backend requires a valid Win32 HWND to hook into the existing message loop rather than spinning up its own. Passing `hwnd: None` caused a crash on startup (v1.17.0). Now retrieves the main window's HWND via `app.get_webview_window("main").hwnd()` and passes it to `PlatformConfig`. Falls back to disabled gracefully if the HWND cannot be obtained.
---
## [1.17.1] - 2026-03-25
### Fixed
- **Windows crash on startup**: souvlaki SMTC init in `setup()` requires a valid HWND and a running COM message loop, neither of which exists at that point. Media controls are disabled on Windows until init can be properly deferred post-window. All other functionality unaffected.
---
## [1.17.0] - 2026-03-25
### Added
- **Media Keys & OS Media Controls** *(experimental)*: Initial integration via [souvlaki](https://github.com/Sinono3/souvlaki) — MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows. Track metadata (title, artist, album, cover art) and playback state are pushed to the OS media overlay in real time. On Linux, init is skipped gracefully if no D-Bus session is present. This feature is still under active development and observation — behaviour may vary across desktop environments and OS versions.
- **Random Mix — Artist Blacklist**: Artist names are now included in the keyword blacklist filter. Clickable artist chips in the tracklist let you add an artist to the blacklist with one click — same UX as the existing genre chips.
- **Favorites — Remove Song**: Each song row in Favorites now has an inline X button to remove the track from favorites instantly (optimistic UI, server unstar happens in the background).
- **3 New Themes**:
- *Games*: **Horde** — Durotar blood-red earth, iron-plate sidebar, forge-fire gold glow on track name.
- *Games*: **Alliance** — Stormwind deep navy, cathedral stone columns, paladin holy-light glow, gold sidebar trim and nav accent.
- *Operating Systems*: **W11** — Windows 11 Fluent Design dark mode. Mica-style sidebar, clean neutral palette, taskbar-inspired player bar. No gradients — faithful to the minimal Fluent aesthetic.
### Changed
- **Theme renames**: Cobalt Media → **WinMedPlayer**, Onyx Cinema → **P-DVD**, Navy Jukebox → **MuMa Jukebox**.
- **NowPlayingDropdown**: Username / player name row now uses `--text-secondary` for improved readability across all themes.
### Fixed
- **Performance — App-wide interaction lag**: Removed `[data-theme='X'] * { font-family: ... !important }` universal selectors from several themes (DOS, Unix, and others). The browser places universal selectors in the "universal bucket" and checks them against every DOM node on every style recalculation — measurably sluggish with 5001000+ elements even when the affected theme is not active. `font-family` is now set on the theme root block (inherits to children) with a targeted `button, input, textarea, select` override for elements that don't inherit font.
- **Performance — Scroll jank**: Removed `repeating-linear-gradient` / `repeating-radial-gradient` from `.app-shell` in DOS, Unix, GW1, Morpheus, Aqua Quartz, and others. WebKitGTK with `WEBKIT_DISABLE_COMPOSITING_MODE=1` (always set by the AUR wrapper) has no GPU compositing — fine-pitch repeating patterns on the full-viewport background re-rasterize every scroll frame. Patterns are now applied only to `.sidebar` and `.player-bar`, which never scroll.
- **Contrast — 29 themes**: Audited all themes against WCAG AA. Fixed `--text-muted` and `--text-secondary` values in 29 themes that had insufficient contrast ratios (< 3.5:1). Affects Catppuccin (all four variants), Gruvbox (all six), Nord variants, GW1, Heisenberg, Ice and Fire, Spider-Tech, Morpheus, Hill Valley 85, Dune, and others.
### Removed
- **Theme**: Azerothian Gold removed from the Games group.
---
## [1.16.0] - 2026-03-24
### Added
- **15 New Themes** across multiple categories:
- *Operating Systems*: **Aqua Quartz** — Mac OS X Aqua (skeuomorphic jelly buttons, brushed aluminium player bar, pinstripe background, blue Source List sidebar, authentic `#3876f7` accent)
- *Movies*: **Spider-Tech** (Spider-Man navy/red), **T-800** (Terminator Skynet blue), **B-Runner** (Blade Runner 2049 amber), **Hill Valley 85** (Back to the Future)
- *Games*: **TetraStack** (Tetris 8-bit, cyan, grid background, 0px radii)
- *Series*: **Turtle Power** (TMNT turtle green, brick tile sidebar)
- *Social Media* (new group): **Insta** (Instagram dark pink), **ReadIt** (Reddit dark orange-red), **The Book** (Facebook light, blue sidebar)
- *Operating Systems*: **W3.1** (Windows 3.1, light silver/teal, 0px radii, inset bevels)
- *Mediaplayer*: **Jayfin** (Jellyfin-inspired — deep black, purple `#AA5CC3` primary, cyan `#00A4DC` secondary, brand gradient on player bar and progress fill)
- **Aqua Quartz — Full Skeuomorphic Polish**: All button variants (`.btn-surface`, `.btn-ghost`, `.hero-play-btn`, `.album-card-details-btn`, `.queue-round-btn`) now have the authentic Aqua jelly gradient. Sidebar sports the iconic blue Source List gradient with white icons and a white pill for the active nav link.
### Changed
- **W98 Theme — Complete Overhaul**: Rebuilt from scratch with authentic Windows 98 design language: correct `#d4d0c8` warm-gray button face (not flat `#c0c0c0`), full 4-layer 3D bevel on all panels and buttons (raised default, sunken on press), song title displays in the iconic navy→light-blue title bar gradient, progress bar is a sunken white trough with navy fill, 16px styled scrollbar, all hover/active states consistently navy `#000080` + white text.
- **Theme Picker — Alphabetical Order**: All theme groups and themes within groups are now sorted alphabetically.
- **Theme Picker — Group Rename**: "Psysonic Themes — Mediaplayer" renamed to "Mediaplayer".
- **Sidebar + Queue Toggle Buttons**: Queue toggle button now uses the theme accent color (icon + hover).
### Fixed
- **AlbumDetail — Genre not propagating**: Playing via the album detail Play All / Enqueue All buttons now correctly includes the track genre in the constructed Track objects, making it show up in the Queue strip.
- **W98 — Theme Accordion active state**: Open category headers are now navy with white text instead of black-on-navy.
- **Aqua Quartz — Sidebar section labels**: "Library" / "System" labels now render in white on the blue sidebar.
- **W98 — Connection indicators**: Server name and Last.fm username in the header are now black (`#000000`) on the warm-gray background for full readability.
### Removed
- **Themes**: Removed **Pandora**, **Order of the Phoenix**, and **Imperial Sith** — too similar to other better-executed themes in their respective groups.
---
## [1.15.0] - 2026-03-23
### Added
- **Queue — Genre · Format · Bitrate Strip**: The meta box above the queue now shows a full-width frosted strip with Genre, audio format, and bitrate (e.g. `Electronic · FLAC · 1411 kbps`). Genre is sourced directly from track metadata and is now propagated through all 11 track construction sites across the codebase.
- **Lyrics — Accent Color Highlight**: The active synced lyrics line is now highlighted in the theme accent color instead of bold+larger text. Eliminates layout jumps caused by the font-weight change pushing lines to wrap.
### Fixed
- **Sidebar — Collapse Button**: The collapse button now correctly sits on the right border of the sidebar, straddling the dividing line between sidebar and main content, and is always visible.
### Changed
- **Queue — Tech Info**: Codec/bitrate badge replaced by the new full-width Genre · Format · Bitrate strip at the top of the meta box.
---
## [1.14.0] - 2026-03-22
### Critical Fixes
- **Prebuffer Flood — 300 simultaneous downloads eliminated**: The audio engine was spawning up to 300 concurrent HTTP download requests during prebuffering, causing network saturation of ~200 Mbit/s and significant CPU load. The root cause was unbounded parallel preload logic in the Rust engine. Fixed: the engine now buffers intelligently with a single controlled preload per track. Network usage dropped to under 100 kbit/s during normal playback.
- **Gapless Playback — fully stable**: Gapless transitions now work correctly end-to-end. Previously, edge cases in the sample-accurate handoff between tracks caused audio glitches or silence between songs.
- **Crossfade — fully stable**: The equal-power crossfade (sin/cos envelope) is now reliable across all track transitions. Previous instability was caused by race conditions in the fade-out trigger and Sink lifecycle management.
- **Now Playing Page — performance**: The Now Playing page no longer causes sustained CPU spikes. Heavy re-renders triggered by frequent `audio:progress` events (previously every 500 ms with wall-clock drift) are resolved — progress is now driven by an atomic sample counter at 100 ms intervals with no layout thrashing.
### Fixed
- **Volume — Clipping at 100%**: Audible distortion at maximum volume eliminated. A `MASTER_HEADROOM` constant of 1 dB (`0.891`) is now applied to all volume calculations, preventing inter-sample peaks from 0 dBFS masters and EQ biquad ripple from clipping.
- **Seek — Display Desync**: Seeking while paused could cause the time display to jump to the new position while audio continued from the old one. `CountingSource::try_seek` now only resets the sample counter after confirming the seek succeeded.
- **Gapless + Crossfade — Mutual Exclusion**: Both modes can no longer be active simultaneously. Enabling one auto-disables the other (Queue toolbar + Settings). Running both simultaneously caused a glitch where Song 2, gapless-chained inside the Sink, would play at full volume after Song 1's crossfade completed.
- **Now Playing — About the Artist**: The "About the Artist" card is now hidden when no biography is available. Artist images that fail to load are silently hidden instead of showing a broken image placeholder.
### Added
- **Waveform — Hover Tooltip**: Hovering over the waveform seekbar shows a floating time label above the cursor. Hidden when no track is loaded or the cursor leaves.
- **Hero & Album Detail — Format Badge**: Audio format (FLAC, MP3, OGG, …) now shown alongside Year, Genre, and Track Count in the hero meta row on the Home page and in the Album Detail header.
- **Help — FLAC Seeking**: New FAQ entry explaining that FLAC files without an embedded SEEKTABLE cannot be seeked, with instructions for adding one via `flac` or `metaflac`.
### Changed
- **Queue — Tech Info**: Codec/bitrate badge moved from the frosted-glass cover overlay into the top-right corner of the meta box. Album artwork is no longer obscured.
---
## [1.13.0] - 2026-03-22
### Added
-247
View File
@@ -1,247 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is Psysonic
A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects.
## Commands
```bash
# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues)
npm run tauri:dev
# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev
# Production build
npm run tauri:build
# Frontend-only dev server (no Tauri shell)
npm run dev
# Type-check + bundle frontend
npm run build
```
There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
## Architecture
### Stack
- **Frontend**: React 18 + TypeScript + Vite, served inside a Tauri WebView
- **Backend**: Rust (Tauri v2) — handles tray icon, media key shortcuts, `exit_app` command, and the full audio engine
- **State**: Zustand stores (no Redux)
- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section.
- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt)
- **Last.fm**: `src/api/lastfm.ts` — direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret from `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET` env vars (bundled at build time).
- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English, German, French, Dutch)
### Key files
| File | Role |
|---|---|
| `src/api/subsonic.ts` | All Subsonic REST calls + `buildStreamUrl` / `buildCoverArtUrl` / `buildDownloadUrl` helpers. Also exports `pingWithCredentials()`, `coverArtCacheKey()`, `reportNowPlaying()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. |
| `src/api/lastfm.ts` | Last.fm API: scrobble, updateNowPlaying, love/unlove, getTrackLoved, getSimilarArtists, getTopArtists/Albums/Tracks, getRecentTracks, getUserInfo. Auth via session key stored in `authStore`. |
| `src/utils/imageCache.ts` | IndexedDB image cache (30-day TTL) + in-memory object URL Map. `getCachedUrl(fetchUrl, cacheKey)` is the main entry point. Capped at 150 entries with LRU eviction + `URL.revokeObjectURL`. Max 5 concurrent fetches. |
| `src/components/CachedImage.tsx` | Drop-in `<img>` replacement that resolves via the image cache. Also exports `useCachedUrl(fetchUrl, cacheKey)` hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. |
| `src/components/TooltipPortal.tsx` | Global tooltip system. Listens for `mouseover`/`mouseout` on `document`, reads `data-tooltip` / `data-tooltip-pos` / `data-tooltip-wrap` attributes, renders via `createPortal` to `document.body` at `z-index: 99999`. Mounted once in `App.tsx`. Use `data-tooltip` instead of native `title=` everywhere — `title=` produces unstyled OS tooltips. |
| `src/components/CustomSelect.tsx` | Styled portal-based dropdown replacing native `<select>`. Accepts `SelectOption[]` with optional `group` and `disabled`. Positioned via `useLayoutEffect`, flips above trigger if near viewport bottom. Use for all select inputs. |
| `src/components/LastfmIcon.tsx` | Shared Last.fm SVG logo component. `<LastfmIcon size={16} />`. |
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
| `src/store/themeStore.ts` | Theme selection (47 themes across 7 groups), applied as `data-theme` on `<html>` |
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
| `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on `<html>`. Persisted in `psysonic_font`. |
| `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. |
| `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. |
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `<TooltipPortal />` mount |
| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. |
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Header shows title + count + duration inline. Crossfade popover (range slider 110 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. |
| `src/components/CoverLightbox.tsx` | Full-screen image lightbox. Props: `{ src, alt, onClose }`. ESC key + overlay click to close. Used in `AlbumHeader` and `ArtistDetail`. |
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
### Multi-server support
`authStore` holds a `ServerProfile[]` array and an `activeServerId`. The `ServerProfile` shape is:
```typescript
interface ServerProfile {
id: string;
name: string;
url: string;
username: string;
password: string;
}
```
Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL.
### Login / connection flow
- Connection is tested with `pingWithCredentials(url, username, password)` from `src/api/subsonic.ts` **before** writing anything to the store.
- Only after a successful ping: `addServer()` + `setActiveServer()` + `setLoggedIn(true)`.
- `RequireAuth` in `App.tsx` redirects to `/login` if `!isLoggedIn || !activeServerId || servers.length === 0`.
- **Do not** call `addServer()` before verifying the connection — this avoids a rehydration race condition with Zustand's async storage.
### Auth salt security
`secureRandomSalt()` in `subsonic.ts` uses `crypto.getRandomValues()` (not `Math.random()`) for all token auth salts.
### Image caching
`buildCoverArtUrl()` generates a new ephemeral URL on every call (new salt) — the browser cache is useless. All cover art and artist images are cached via:
- `coverArtCacheKey(id, size)` — stable key: `${serverId}:cover:${id}:${size}`
- `CachedImage` component or `useCachedUrl` hook — resolve via IndexedDB, fall back to direct URL
- Use `useCachedUrl` (not `CachedImage`) for CSS `background-image` properties
- **Gotcha**: `useCachedUrl` / hooks from `CachedImage.tsx` must be called unconditionally, before any early `return` in the component. Derive inputs from nullable state (e.g. `album?.album.coverArt`) rather than placing the hook after guard returns.
### Data flow
1. `authStore.getBaseUrl()` returns the active server's URL
2. `subsonic.ts` calls `useAuthStore.getState()` directly (not hooks) to build each request
3. `playerStore.playTrack()` calls `invoke('audio_play', { url, volume, durationHint })`, calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm), listens for `audio:progress` / `audio:ended` events, triggers scrobble at 50% directly via Last.fm API, and debounces server queue sync
4. Tauri events (`media:play-pause`, `tray:play-pause`, etc.) are bridged to store actions in `TauriEventBridge` inside `App.tsx`
5. On cold start (app restart): if `currentTrack` is in localStorage, `resume()` calls `audio_play` + seeks to saved `currentTime`
### Adding a new page
1. Create `src/pages/MyPage.tsx`
2. Add a `<Route>` in `AppShell` in `src/App.tsx`
3. Add a sidebar link in `src/components/Sidebar.tsx`
4. Add i18n keys to both `enTranslation` and `deTranslation` in `src/i18n.ts`
### Adding a new Subsonic API call
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes
47 themes across 7 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
| Theme | Group | Style | Accent |
|---|---|---|---|
| `poison` | Psysonic Themes | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
| `nucleo` | Psysonic Themes | warm brass/cream light | Brass `#7a5218` |
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
| `wnamp` | Psysonic — Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
| `heisenberg` | Series | Breaking Bad | Crystal Blue `#3fe0ff` |
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
| `latte` | Open Source Classics | Catppuccin light | Mauve |
| `nord` | Open Source Classics | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Open Source Classics | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Open Source Classics | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Open Source Classics | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Open Source Classics | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Open Source Classics | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Open Source Classics | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Open Source Classics | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Open Source Classics | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Open Source Classics | Gruvbox light soft | Orange `#af3a03` |
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
### Artists page — initial avatars
Artist images are intentionally **not loaded** on the Artists overview page (grid + list view) to avoid slow server disk I/O on large libraries. Instead, each artist gets a colour-coded initial avatar: first letter of the name (skipping leading punctuation/numbers), colour deterministically hashed from the name using Catppuccin palette variables. Artist images are still loaded on the individual ArtistDetail page (cached via IndexedDB). In the grid view, the initial avatar is a **circle** (`border-radius: 50%`, `border: 2px solid` with the accent colour) centred inside the card. Name and album count below are centre-aligned.
### Artist cards
`ArtistCardLocal` uses the same structure as `AlbumCard`: no padding, full-width square cover via `aspect-ratio: 1`, info below. Both use `flex: 0 0 clamp(140px, 15vw, 180px)` inside `.album-grid` so they stay the same size as album cards.
### NowPlayingDropdown — Live button
`src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available.
### i18n
All non-English strings live exclusively in `src/i18n.ts` — never hardcode translated text in `.tsx` files. Four languages: `en`, `de`, `fr`, `nl`. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
**German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
### Tauri capabilities
Tauri v2 capability configs live in `src-tauri/capabilities/`. Schema is auto-generated into `src-tauri/gen/schemas/`. Modify capabilities there when adding new Tauri plugins or IPC commands.
## Release / CI
Releases are triggered by pushing a `v*` tag. The GitHub Actions workflow (`.github/workflows/release.yml`) builds for macOS (arm64 + x86_64), Linux (Ubuntu 24.04 → deb + rpm), and Windows.
The workflow is split into three jobs: `create-release` (creates the GitHub Release with changelog body from CHANGELOG.md), `build-macos-windows` (macOS + Windows via tauri-action), and `build-linux` (Ubuntu 24.04, manual, builds only deb + rpm via `--bundles deb,rpm`).
**AppImage is no longer built.** The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to the bundled WebKitGTK conflicting with the system's Mesa/EGL stack.
**Never force-push or move a tag after publishing.** GitHub caches release tarballs — moving a tag causes the AUR and other package managers to build stale code. Bump the patch version instead.
### Linux distribution channels
| Distro family | Package |
|---|---|
| Ubuntu / Debian | `.deb` from GitHub Releases |
| Fedora / RHEL | `.rpm` from GitHub Releases |
| Arch / CachyOS | AUR: `yay -S psysonic` or `paru -S psysonic` |
### AUR package (`packages/aur/PKGBUILD`)
- Maintained at `aur.archlinux.org/packages/psysonic` (account: Psychotoxical)
- Builds from source using the system's own WebKitGTK — no bundled libs, no EGL issues
- Installs a wrapper script at `/usr/bin/psysonic` setting `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1`
- **When releasing**: bump `pkgver` in `packages/aur/PKGBUILD`, copy to `~/aur-psysonic/`, run `makepkg --printsrcinfo > .SRCINFO`, commit and push to AUR remote
## Notes
- Media key shortcuts (`MediaPlayPause`, etc.) are **not registered on Linux** (see `#[cfg(not(target_os = "linux"))]` in `lib.rs`). Spacebar is the keyboard shortcut for play/pause instead.
- `playerStore` persists `volume`, `repeatMode`, `currentTrack`, `queue`, `queueIndex`, and `currentTime` to `localStorage` via Zustand `partialize`. The audio engine state is runtime-only (Rust side).
- Auth data is persisted via **`localStorage`** (synchronous Zustand storage). Do **not** switch to `@tauri-apps/plugin-store` for `authStore` — async storage causes a rehydration race condition where `getActiveServer()` returns `undefined` before state is restored.
- `tauri.conf.json` CSP is set to `null` — a stricter CSP breaks HTTP requests in WebKitGTK on Linux.
- App logo: `public/logo.png` (used in login page and elsewhere). All platform icons generated from this via `npx tauri icon public/logo.png`.
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via wall-clock (`seek_offset + elapsed`) clamped to `duration_secs`**not** `sink.empty()` (unreliable in rodio 0.19). `audio:ended` fires after 2 consecutive ticks where `pos >= dur - 1.0s`. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert.
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` and updates `seek_offset` + `play_started`.
- **Cold-start resume**: `resume()` checks `isAudioPaused` flag. If true (warm resume), calls `audio_resume`. If false (cold start after restart), calls `audio_play` with saved URL then `audio_seek` to saved `currentTime`. Position preference: server queue position > 0 → use server; otherwise use localStorage value.
- **Drag-and-drop**: All drag sources use `dataTransfer.setData('text/plain', ...)` — WebView2 (Windows) does not support custom MIME types like `application/json`. Queue reordering calculates the **drop target index from `e.clientY`** at drop time (iterates `[data-queue-idx]` elements, picks the first whose midpoint is below the cursor). `fromIdx` comes from `dataTransfer` (set in `dragstart`, always reliable). `onDragEnd` clears refs synchronously. All drops are handled by the `<aside>` container — no `onDrop` on individual queue items.
- **Drag-and-drop cursor (Linux)**: WebKitGTK does not honour `dropEffect` for cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content.
- **Fullscreen Player ("Ambient Stage")**: Single centered column — no tracklist. Background uses the artist's `largeImageUrl` from `getArtistInfo()` (falls back to cover art). Ken Burns animation: `inset: -30%`, ±8% translate, 90s cycle. No color orbs (removed — too GPU-intensive). Cover has a slow breathing animation (`cover-breathe` keyframe). Long song titles scroll as a marquee (`MarqueeTitle` component — measures overflow via `getBoundingClientRect` + `ResizeObserver`, animates via CSS custom property `--scroll-amount`). `Track.artistId` is populated from `SubsonicSong.artistId` (Navidrome returns this field) across all 18 track-construction sites.
- **Sidebar**: Fixed width via CSS `clamp(200px, 15vw, 220px)` — no drag-to-resize. Collapsed state (72px) persisted in `localStorage`. Update notification uses Tauri Shell plugin `open()` to launch the system browser — `<a target="_blank">` does not work inside a Tauri WebView.
- **Artist page — external links**: Last.fm and Wikipedia buttons open in the system browser via `open()` from `@tauri-apps/plugin-shell`. Button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 s as visual confirmation.
- **Tracklist columns**: Order is `# | Title | [Artist (VA only)] | Favorite | Rating | Duration | Format`. Format column uses `120px` (NOT `auto` or `1fr`) — `auto` caused misalignment because header and track-row are independent grid containers: "FORMAT" header text is narrower than "MP3 · 320 kbps", so the `fr` title column calculated differently in header vs rows, shifting all subsequent columns. `1fr` fixed alignment but made the format column too wide. Fixed `120px` fits all codec strings (MP3/FLAC/OGG · kbps) and aligns perfectly. Total row uses explicit `grid-column` numbers (not negative indices).
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
- **Random Mix — Genre Filter**: `excludeAudiobooks` + `customGenreBlacklist` in `authStore` (persisted). Hardcoded `AUDIOBOOK_GENRES` list in `RandomMix.tsx` and `AUDIOBOOK_GENRES_DISPLAY` in `Settings.tsx` must be kept in sync. Filter checks `song.genre`, `song.title`, and `song.album`. Clickable genre chips in the tracklist let users add genres to the blacklist on the fly.
- **Random Mix — Super Genre Mix**: 9 super-genres defined in `SUPER_GENRES` constant. Server genres fetched via `getGenres()` on mount; `availableSuperGenres` filters to those with ≥1 keyword match. `loadGenreMix` uses progressive rendering — `setGenreMixSongs` updated after each genre request resolves. Genre list capped at 50 (randomly sampled) so total fetch stays near 50 songs — no over-fetching. `genreMixComplete` state gates the "Play All" button: button stays `btn-surface` with live `n / 50` counter while loading, switches to `btn-primary` only when all songs are ready.
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
- **Tooltips**: Use `data-tooltip="text"` on any element — never native `title=`. `data-tooltip-pos="top|bottom|left|right"` (default: top). `data-tooltip-wrap` for multi-line. Rendered by `TooltipPortal` in `App.tsx` via `document.body` portal.
- **Scrobbling**: At 50% playback, `playerStore` calls `lastfmScrobble()` directly. Navidrome is NOT used for scrobbling. Both `reportNowPlaying` (Navidrome) and `lastfmUpdateNowPlaying` (Last.fm) are called on every `playTrack()` — independently, fire-and-forget.
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
- **Version**: 1.13.0
+11 -9
View File
@@ -22,9 +22,9 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ Features
- 🎨 **Gorgeous UI**: 47 deeply integrated themes across 7 groups — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Psysonic Mediaplayer — with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**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, and Dutch.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
- 📻 **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 and line highlighting, plain-text fallback.
@@ -36,6 +36,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- ⌨️ **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.
- 🎼 **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.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
@@ -54,24 +55,25 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
- [x] Multi-server support
- [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix
- [x] 47 themes across 7 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Psysonic originals, Psysonic Mediaplayer
- [x] Internationalization (English, German, French, Dutch)
- [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] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
### 📋 Planned
- [ ] FLAC seeking fix
- [ ] General UI polish & visual refinement
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
---
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
Some known bugs actively working on fixes
## 📥 Installation
+5 -15
View File
@@ -1,18 +1,17 @@
{
"name": "psysonic",
"version": "1.8.0",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.8.0",
"version": "1.21.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-window-state": "^2.4.1",
@@ -1494,15 +1493,6 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
@@ -2302,9 +2292,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.13.0",
"version": "1.24.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -15,7 +15,6 @@
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-window-state": "^2.4.1",
+1 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.13.0
pkgver=1.23.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -9,7 +9,6 @@ license=('GPL-3.0-only')
depends=(
'webkit2gtk-4.1'
'gtk3'
'libayatana-appindicator'
'openssl'
'alsa-lib'
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+500 -179
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.12.0"
version = "1.24.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -23,7 +23,6 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
@@ -31,8 +30,10 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
tauri-plugin-window-state = "2.4.1"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
<key>com.apple.security.device.audio-input</key>
<false/>
</dict>
</plist>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music player only — it does not record audio.
This description is shown if macOS prompts for microphone access
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
</dict>
</plist>
-1
View File
@@ -8,7 +8,6 @@
"core:default",
"shell:default",
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:default",
File diff suppressed because one or more lines are too long
+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://**"}]},"notification:default","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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-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"],"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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-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"],"platforms":["linux","macOS","windows"]}}
-198
View File
@@ -6014,204 +6014,6 @@
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
-198
View File
@@ -6014,204 +6014,6 @@
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
+374 -40
View File
@@ -1,12 +1,21 @@
use std::io::Cursor;
use std::io::{Cursor, Read, Seek};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Decoder, Sink, Source};
use rodio::{Sink, Source};
use rodio::source::UniformSourceIterator;
use serde::Serialize;
use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream},
meta::MetadataOptions,
probe::Hint,
units::{self, Time},
};
use tauri::{AppHandle, Emitter, State};
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
@@ -380,11 +389,275 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// Reset counter to the sought position in samples.
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
* self.inner.channels() as f64) as u64;
self.counter.store(samples, Ordering::Relaxed);
self.inner.try_seek(pos)
// Reset counter only after confirming the inner seek succeeded.
// If we reset first and the seek fails, the counter ends up at the
// new position while the decoder is still at the old one — causing
// a permanent desync between displayed time and actual audio.
let result = self.inner.try_seek(pos);
if result.is_ok() {
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
* self.inner.channels() as f64) as u64;
self.counter.store(samples, Ordering::Relaxed);
}
result
}
}
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
//
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
// byte_len() → None. This tells symphonia "stream length unknown", which
// prevents the FLAC demuxer from seeking (it validates seek offsets against
// the total stream length from byte_len). MP3 is unaffected because its
// demuxer uses Xing/LAME headers instead.
//
// This wrapper provides the actual byte length, fixing seek for all formats.
struct SizedCursorSource {
inner: Cursor<Vec<u8>>,
len: u64,
}
impl Read for SizedCursorSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for SizedCursorSource {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for SizedCursorSource {
fn is_seekable(&self) -> bool { true }
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
// (byte_len = None). This constructs the symphonia pipeline directly,
// providing the correct byte_len via SizedCursorSource.
//
// Implements Iterator<Item = i16> + Source — identical interface to
// rodio::Decoder, so the rest of the source chain is unchanged.
const DECODE_MAX_RETRIES: usize = 3;
struct SizedDecoder {
decoder: Box<dyn symphonia::core::codecs::Decoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
buffer: SampleBuffer<i16>,
spec: SignalSpec,
}
impl SizedDecoder {
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> {
let data_len = data.len() as u64;
let source = SizedCursorSource {
inner: Cursor::new(data),
len: data_len,
};
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
Default::default(),
);
let mut hint = Hint::new();
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("probe failed: {e}"))?;
let track = probed.format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| "no supported audio track".to_string())?;
let track_id = track.id;
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("codec init failed: {e}"))?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
Err(e) => return Err(format!("first packet: {e}")),
};
if packet.track_id() != track_id { continue; }
match decoder.decode(&packet) {
Ok(decoded) => break decoded,
Err(symphonia::core::errors::Error::DecodeError(_)) => {
decode_errors += 1;
if decode_errors > DECODE_MAX_RETRIES {
return Err("too many decode errors".into());
}
}
Err(e) => return Err(format!("decode: {e}")),
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
Ok(SizedDecoder {
decoder,
current_frame_offset: 0,
format,
total_duration,
buffer,
spec,
})
}
#[inline]
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
/// Refine position after a coarse seek — decode packets until we reach the
/// exact requested timestamp.
fn refine_position(
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
let packet = loop {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
Ok(())
}
}
impl Iterator for SizedDecoder {
type Item = i16;
#[inline]
fn next(&mut self) -> Option<i16> {
if self.current_frame_offset >= self.buffer.len() {
let packet = self.format.next_packet().ok()?;
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet().ok()?;
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.ok()?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = 0;
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
}
impl Source for SizedDecoder {
#[inline]
fn current_frame_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> u16 {
self.spec.channels.count() as u16
}
#[inline]
fn sample_rate(&self) -> u32 {
self.spec.rate
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
let seek_beyond_end = self
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
// Step back a tiny bit — some demuxers can't seek to the exact end.
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
} else {
pos.as_secs_f64().into()
};
let to_skip = self.current_frame_offset % self.channels() as usize;
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| rodio::source::SeekError::Other(
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
))?;
self.refine_position(seek_res)
.map_err(|e| rodio::source::SeekError::Other(
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
))?;
self.current_frame_offset += to_skip;
Ok(())
}
}
@@ -467,6 +740,7 @@ struct BuiltSource {
///
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
fn build_source(
data: Vec<u8>,
duration_hint: f64,
@@ -476,11 +750,11 @@ fn build_source(
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
target_rate: u32,
format_hint: Option<&str>,
) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data);
let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
let decoder = SizedDecoder::new(data, format_hint)?;
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
@@ -693,7 +967,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
gapless_enabled: Arc::new(AtomicBool::new(false)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44100)),
current_sample_rate: Arc::new(AtomicU32::new(0)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
};
@@ -732,6 +1006,12 @@ async fn fetch_data(
return Ok(Some(data));
}
// Offline cache — local file written by download_track_offline.
if let Some(path) = url.strip_prefix("psysonic-local://") {
let data = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
return Ok(Some(data));
}
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
@@ -746,6 +1026,12 @@ async fn fetch_data(
Ok(Some(data))
}
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
const MASTER_HEADROOM: f32 = 0.891_254;
fn compute_gain(
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
@@ -756,7 +1042,7 @@ fn compute_gain(
.unwrap_or(1.0);
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
let gain_linear = gain_linear.min(1.0 / peak);
let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
(gain_linear, effective)
}
@@ -862,7 +1148,13 @@ pub async fn audio_play(
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
// Extract format hint from URL for better symphonia probing.
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
let built = build_source(
data,
duration_hint,
@@ -872,6 +1164,7 @@ pub async fn audio_play(
fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint.as_deref(),
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source;
let duration_secs = built.duration_secs;
@@ -1028,12 +1321,16 @@ pub async fn audio_chain_preload(
if let Some(d) = cached {
d
} else {
let resp = state.http_client.get(&url).send().await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = state.http_client.get(&url).send().await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
}
resp.bytes().await.map_err(|e| e.to_string())?.into()
}
resp.bytes().await.map_err(|e| e.to_string())?.into()
}
};
@@ -1048,7 +1345,11 @@ pub async fn audio_chain_preload(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
// Always 0 — no application-level resampling (same as audio_play).
let target_rate: u32 = 0;
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
let built = build_source(
data,
duration_hint,
@@ -1058,6 +1359,7 @@ pub async fn audio_chain_preload(
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
format_hint.as_deref(),
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
@@ -1122,6 +1424,8 @@ fn spawn_progress_task(
let mut near_end_ticks: u32 = 0;
// Local done-flag reference; swapped on gapless transition.
let mut current_done = initial_done;
// Local sample counter; swapped to chained source's counter on transition.
let mut samples_played = samples_played;
loop {
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
@@ -1141,12 +1445,11 @@ fn spawn_progress_task(
// Swap to the chained source's done flag.
current_done = info.source_done;
// Swap the sample counter: the chained source's counter
// is already being incremented by CountingSource. Copy its
// current value into the shared samples_played so the
// progress calculation stays accurate.
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
samples_played.store(chained_samples, Ordering::Relaxed);
// Swap to the chained source's sample counter.
// The chained CountingSource increments its own Arc,
// so we must rebind our local reference to it —
// a one-time value copy would go stale immediately.
samples_played = info.sample_counter;
// Update tracking state.
{
@@ -1212,6 +1515,15 @@ fn spawn_progress_task(
near_end_ticks += 1;
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
if near_end_ticks >= 10 {
// If a gapless chain is pending, the source hasn't
// exhausted yet — duration_hint (integer seconds from
// Subsonic) is shorter than the actual audio content.
// Don't emit audio:ended; let the gapless transition
// handle it when current_done fires.
let has_chain = chained_arc.lock().unwrap().is_some();
if has_chain {
continue;
}
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
@@ -1280,7 +1592,7 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
}
}
// Seeking far back invalidates any pending gapless chain.
// Seeking back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
@@ -1290,15 +1602,17 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
}
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
.map_err(|e: rodio::source::SeekError| e.to_string())?;
if cur.paused_at.is_some() {
cur.paused_at = Some(seconds);
} else {
cur.seek_offset = seconds;
cur.play_started = Some(Instant::now());
}
if cur.sink.is_none() { return Ok(()); }
cur.sink.as_ref().unwrap()
.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
.map_err(|e| e.to_string())?;
if cur.paused_at.is_some() {
cur.paused_at = Some(seconds);
} else {
cur.seek_offset = seconds;
cur.play_started = Some(Instant::now());
}
Ok(())
}
@@ -1308,7 +1622,23 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0));
}
}
#[tauri::command]
pub fn audio_update_replay_gain(
volume: f32,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
state: State<'_, AudioEngine>,
) {
let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let mut cur = state.current.lock().unwrap();
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume(effective);
}
}
@@ -1332,11 +1662,15 @@ pub async fn audio_preload(
return Ok(());
}
}
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
Ok(())
+242 -78
View File
@@ -6,16 +6,16 @@ mod audio;
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
use tauri::{Emitter, Manager};
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
@@ -138,6 +138,154 @@ fn unregister_global_shortcut(
app.global_shortcut().unregister(parsed).map_err(|e| e.to_string())
}
#[tauri::command]
fn mpris_set_metadata(
controls: tauri::State<MprisControls>,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
cover_url: Option<String>,
duration_secs: Option<f64>,
) -> Result<(), String> {
use souvlaki::MediaMetadata;
use std::time::Duration;
let duration = duration_secs.map(|s| Duration::from_secs_f64(s));
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
album: album.as_deref(),
cover_url: cover_url.as_deref(),
duration,
})
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
#[tauri::command]
fn mpris_set_playback(
controls: tauri::State<MprisControls>,
playing: bool,
position_secs: Option<f64>,
) -> Result<(), String> {
use souvlaki::{MediaPlayback, MediaPosition};
use std::time::Duration;
let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s)));
let playback = if playing {
MediaPlayback::Playing { progress }
} else {
MediaPlayback::Paused { progress }
};
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
ctrl.set_playback(playback)
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
}
// ─── Offline Track Cache ──────────────────────────────────────────────────────
/// Downloads a single track to the app's offline cache directory.
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
#[tauri::command]
async fn download_track_offline(
track_id: String,
server_id: String,
url: String,
suffix: String,
app: tauri::AppHandle,
) -> Result<String, String> {
let cache_dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id);
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
// Already cached — skip re-download.
if file_path.exists() {
return Ok(path_str);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
Ok(path_str)
}
/// Returns the total size in bytes of all files in the offline cache directory.
#[tauri::command]
async fn get_offline_cache_size(app: tauri::AppHandle) -> u64 {
let offline_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
Err(_) => return 0,
};
if !offline_dir.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![offline_dir];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
/// Removes a cached track from the offline cache directory.
#[tauri::command]
async fn delete_offline_track(
track_id: String,
server_id: String,
suffix: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
.join(format!("{}.{}", track_id, suffix));
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -146,111 +294,127 @@ pub fn run() {
.manage(ShortcutMap::default())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
// Build tray menu
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let separator = tauri::menu::PredefinedMenuItem::separator(app)?;
let show = MenuItemBuilder::with_id("show", "Show Psysonic").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit").build(app)?;
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
{
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&next)
.item(&separator)
.item(&show)
.item(&quit)
.build()?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => {
let _ = app.emit("tray:play-pause", ());
}
"next" => {
let _ = app.emit("tray:next", ());
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
// Collect pre-conditions and the platform-specific HWND.
// Returns None early (with a log) on any unrecoverable condition
// so app.manage() always executes exactly once at the bottom.
let maybe_controls: Option<MediaControls> = (|| {
// Linux: requires a live D-Bus session.
#[cfg(target_os = "linux")]
{
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
.map(|v| !v.is_empty())
.unwrap_or(false);
if !dbus_ok {
eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
return None;
}
}
"quit" => {
std::process::exit(0);
}
_ => {}
})
.on_tray_icon_event(|_tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
// Left click shows app (handled in JS side via tray event)
}
})
.build(app)?;
// Register media key global shortcuts (all platforms)
{
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
for shortcut_str in &shortcuts {
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
let shortcut_clone = shortcut_str.to_string();
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match shortcut_clone.as_str() {
"MediaPlayPause" => "media:play-pause",
"MediaNextTrack" => "media:next",
"MediaPreviousTrack" => "media:prev",
_ => return,
};
let _ = app.emit(event_name, ());
// Windows: souvlaki SMTC must hook into the existing Win32
// message loop rather than spinning up its own. Pass the
// main window's HWND so it can do so. If we can't get one,
// skip init (no crash, just no media overlay).
#[cfg(target_os = "windows")]
let hwnd = {
use tauri::Manager;
let h = app.get_webview_window("main")
.and_then(|w| w.hwnd().ok())
.map(|h| h.0 as *mut std::ffi::c_void);
if h.is_none() {
eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled");
return None;
}
h
};
#[cfg(not(target_os = "windows"))]
let hwnd: Option<*mut std::ffi::c_void> = None;
let config = PlatformConfig {
dbus_name: "psysonic",
display_name: "Psysonic",
hwnd,
};
match MediaControls::new(config) {
Ok(mut controls) => {
let app_handle = app.handle().clone();
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
match event {
MediaControlEvent::Toggle
| MediaControlEvent::Play
| MediaControlEvent::Pause => {
let _ = app_handle.emit("media:play-pause", ());
}
MediaControlEvent::Next => {
let _ = app_handle.emit("media:next", ());
}
MediaControlEvent::Previous => {
let _ = app_handle.emit("media:prev", ());
}
MediaControlEvent::Seek(direction) => {
use souvlaki::SeekDirection;
let delta: f64 = match direction {
SeekDirection::Forward => 5.0,
SeekDirection::Backward => -5.0,
};
let _ = app_handle.emit("media:seek-relative", delta);
}
MediaControlEvent::SetPosition(pos) => {
let secs = pos.0.as_secs_f64();
let _ = app_handle.emit("media:seek-absolute", secs);
}
_ => {}
}
}) {
eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
}
});
Some(controls)
}
Err(e) => {
eprintln!("[Psysonic] Could not create media controls: {e:?}");
None
}
}
}
})();
app.manage(MprisControls::new(maybe_controls));
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
// Only intercept close for the main window (hide to tray).
// Browser popup windows (browser_*) close normally.
if window.label() == "main" {
let _ = window.emit("window:close-requested", ());
}
}
})
.invoke_handler(tauri::generate_handler![
greet,
exit_app,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
mpris_set_playback,
audio::audio_play,
audio::audio_pause,
audio::audio_resume,
audio::audio_stop,
audio::audio_seek,
audio::audio_set_volume,
audio::audio_update_replay_gain,
audio::audio_set_eq,
audio::audio_preload,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
lastfm_request,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+7 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.13.0",
"version": "1.24.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -55,9 +55,13 @@
"bundleMediaFramework": true
}
},
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15"
},
"windows": {
"wix": {
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
"nsis": {
"installMode": "currentUser"
}
}
}
+179 -24
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -22,20 +22,31 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
import Statistics from './pages/Statistics';
import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import OfflineBanner from './components/OfflineBanner';
import OfflineLibrary from './pages/OfflineLibrary';
import Genres from './pages/Genres';
import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useFontStore } from './store/fontStore';
@@ -59,6 +70,26 @@ function AppShell() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
const navigate = useNavigate();
const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus);
useEffect(() => {
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
navigate('/offline', { replace: true });
}
// Return from offline page only when reconnecting (not when user navigates there manually while online)
if (connStatus === 'connected' && prev === 'disconnected' && location.pathname === '/offline') {
navigate('/', { replace: true });
}
}, [connStatus, hasOfflineContent, location.pathname, navigate]);
useEffect(() => {
initializeFromServerQueue();
@@ -86,6 +117,15 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
useEffect(() => {
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
setChangelogModalOpen(true);
}
}, []);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
});
@@ -126,6 +166,33 @@ function AppShell() {
};
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
// ── Global DnD fix for Linux/WebKitGTK ──────────────────────────
// WebKitGTK (used by Tauri on Linux) requires the document itself to
// accept drags via preventDefault() on dragover/dragenter. Without
// this, the webview shows a "forbidden" cursor for all in-app HTML5
// drag-and-drop because it never sees a valid drop target at the
// document level. This is harmless on Windows/macOS where DnD already
// works correctly.
useEffect(() => {
const allow = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
// Prevent the webview from navigating when something (e.g. a file
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
};
}, []);
return (
<div
className="app-shell"
@@ -135,9 +202,9 @@ function AppShell() {
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
<main className="main-content">
<header className="content-header">
@@ -147,16 +214,19 @@ function AppShell() {
<LastfmIndicator />
<NowPlayingDropdown />
<button
className="collapse-btn"
className="queue-toggle-btn"
onClick={toggleQueue}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
{connStatus === 'disconnected' && hasOfflineContent && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
)}
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
{connStatus === 'disconnected' && (
{connStatus === 'disconnected' && !hasOfflineContent && (
<OfflineOverlay
serverName={serverName}
onRetry={connRetry}
@@ -173,13 +243,18 @@ function AppShell() {
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random-mix" element={<RandomMix />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/now-playing" element={<NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
<Route path="/genres" element={<Genres />} />
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
</Routes>
</div>
</main>
@@ -199,16 +274,16 @@ function AppShell() {
<ContextMenu />
<DownloadFolderModal />
<TooltipPortal />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div>
);
}
// Tray / media key event handler
// Media key event handler
function TauriEventBridge() {
const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const { minimizeToTray } = useAuthStore();
// Configurable keybindings
useEffect(() => {
@@ -268,8 +343,6 @@ function TauriEventBridge() {
['media:prev', () => previous()],
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
['tray:play-pause', () => togglePlay()],
['tray:next', () => next()],
];
for (const [event, handler] of handlers) {
const u = await listen(event, handler);
@@ -277,15 +350,32 @@ function TauriEventBridge() {
unlisten.push(u);
}
// Handle close → minimize to tray if enabled (Tauri 2 approach)
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
{
const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(Math.max(0, s.currentTime + e.payload) / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:seek-absolute', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(e.payload / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Close → exit app
const win = getCurrentWindow();
const u = await win.onCloseRequested(async (event) => {
if (minimizeToTray) {
event.preventDefault();
await win.hide();
} else {
await invoke('exit_app');
}
const u = await win.onCloseRequested(async () => {
await invoke('exit_app');
});
if (cancelled) { u(); return; }
unlisten.push(u);
@@ -293,7 +383,7 @@ function TauriEventBridge() {
setup();
return () => { cancelled = true; unlisten.forEach(u => u()); };
}, [togglePlay, next, previous, minimizeToTray]);
}, [togglePlay, next, previous]);
return null;
}
@@ -301,6 +391,7 @@ function TauriEventBridge() {
export default function App() {
const theme = useThemeStore(s => s.theme);
const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
@@ -318,6 +409,67 @@ export default function App() {
useGlobalShortcutsStore.getState().registerAll();
}, []);
// ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ──
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || !e.shiftKey || !e.altKey || e.code !== 'KeyN') return;
e.preventDefault();
setExportPickerOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleExport = async (since: number) => {
setExportPickerOpen(false);
const showToast = (text: string) => {
const toast = document.createElement('div');
toast.textContent = text;
toast.style.cssText = `
position:fixed; bottom:100px; left:50%; transform:translateX(-50%);
background:#24273a; color:#cad3f5; border:1px solid #363a4f;
padding:10px 20px; border-radius:10px; font-size:14px;
z-index:999999; pointer-events:none;
box-shadow:0 4px 24px rgba(0,0,0,0.5);
white-space:nowrap;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
};
try {
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
const result = await exportNewAlbumsImage(since);
if (result) {
const files = result.paths.length > 1 ? ` (${result.paths.length} Dateien)` : '';
showToast(`📸 ${result.count} Alben exportiert${files}`);
} else {
showToast('📭 Keine Alben in diesem Zeitraum gefunden');
}
} catch (err) {
showToast(`❌ Export fehlgeschlagen: ${String(err).slice(0, 80)}`);
console.error('[easter egg] export failed:', err);
}
};
useEffect(() => {
const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
const onScroll = (e: Event) => {
const el = e.target as HTMLElement;
el.classList.add('is-scrolling');
const existing = timers.get(el);
if (existing !== undefined) clearTimeout(existing);
timers.set(el, setTimeout(() => {
el.classList.remove('is-scrolling');
timers.delete(el);
}, 800));
};
document.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('scroll', onScroll, true);
timers.forEach(t => clearTimeout(t));
};
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
@@ -327,11 +479,14 @@ export default function App() {
path="/*"
element={
<RequireAuth>
<AppShell />
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
</BrowserRouter>
);
}
+22 -4
View File
@@ -52,6 +52,7 @@ export interface SubsonicAlbum {
genre?: string;
starred?: string;
recordLabel?: string;
created?: string;
}
export interface SubsonicSong {
@@ -229,8 +230,19 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
export async function getGenres(): Promise<SubsonicGenre[]> {
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
return data.genres?.genre ?? [];
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
const raw = data.genres?.genre;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'byGenre', genre, size, offset, _t: Date.now(),
});
const raw = data.albumList2?.album;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
export interface SearchResults {
@@ -372,12 +384,18 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<void> {
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
await api('createPlaylist.view', params);
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
}
export async function deletePlaylist(id: string): Promise<void> {
+34 -11
View File
@@ -1,19 +1,29 @@
import React from 'react';
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { Play, HardDriveDownload } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
}
export default function AlbumCard({ album }: AlbumCardProps) {
function AlbumCard({ album }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
return (
<div
@@ -27,14 +37,20 @@ export default function AlbumCard({ album }: AlbumCardProps) {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
}));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="album-card-cover">
@@ -48,6 +64,11 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</svg>
</div>
)}
{isOffline && (
<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"
@@ -66,3 +87,5 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</div>
);
}
export default memo(AlbumCard);
+35 -5
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
@@ -70,10 +70,14 @@ interface AlbumHeaderProps {
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
onToggleStar: () => void;
onDownload: () => void;
onCacheOffline: () => void;
onRemoveOffline: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onBio: () => void;
@@ -88,10 +92,14 @@ export default function AlbumHeader({
resolvedCoverUrl,
isStarred,
downloadProgress,
offlineStatus,
offlineProgress,
bio,
bioOpen,
onToggleStar,
onDownload,
onCacheOffline,
onRemoveOffline,
onPlayAll,
onEnqueueAll,
onBio,
@@ -103,6 +111,7 @@ export default function AlbumHeader({
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
return (
<>
@@ -159,6 +168,7 @@ export default function AlbumHeader({
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{formatLabel && <span>· {formatLabel}</span>}
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
@@ -214,10 +224,30 @@ export default function AlbumHeader({
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-ghost offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)}
</div>
</div>
</div>
+34 -29
View File
@@ -1,8 +1,9 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Play, Star } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { Track } from '../store/playerStore';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -45,8 +46,6 @@ interface AlbumTrackListProps {
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
hoveredSongId: string | null;
setHoveredSongId: (id: string | null) => void;
ratings: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
@@ -60,8 +59,6 @@ export default function AlbumTrackList({
hasVariousArtists,
currentTrack,
isPlaying,
hoveredSongId,
setHoveredSongId,
ratings,
starredSongs,
onPlaySong,
@@ -70,6 +67,13 @@ export default function AlbumTrackList({
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
@@ -79,22 +83,14 @@ export default function AlbumTrackList({
discs.get(disc)!.push(song);
});
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1;
const makeTrack = (song: SubsonicSong): Track => ({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred,
});
const isMultiDisc = discNums.length > 1;
return (
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
@@ -112,19 +108,30 @@ export default function AlbumTrackList({
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div
@@ -146,11 +153,9 @@ export default function AlbumTrackList({
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
@@ -178,7 +183,7 @@ export default function AlbumTrackList({
</div>
))}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
+18 -3
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -17,7 +17,22 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
return resolved || fetchUrl;
}
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey);
return <img src={resolvedSrc} {...props} />;
const [loaded, setLoaded] = useState(false);
const prevSrc = useRef('');
if (resolvedSrc !== prevSrc.current) {
prevSrc.current = resolvedSrc;
setLoaded(false);
}
return (
<img
src={resolvedSrc}
style={{ ...style, opacity: loaded ? 1 : 0, transition: loaded ? 'opacity 0.15s ease' : 'none' }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
{...props}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useAuthStore } from '../store/authStore';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
interface Props {
onClose: () => void;
}
export default function ChangelogModal({ onClose }: Props) {
const { t } = useTranslation();
const [dontShow, setDontShow] = useState(false);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
const currentVersionData = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
if (!block) return null;
const lines = block.split('\n');
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
const body = lines.slice(1).join('\n').trim();
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
}, []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
setLastSeenChangelogVersion(version);
onClose();
};
if (!currentVersionData) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
<div
className="eq-popup"
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
>
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
{t('changelog.modalTitle')} v{version}
</span>
{currentVersionData.date && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{currentVersionData.date}
</span>
)}
</div>
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
{currentVersionData.body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderTop: '1px solid var(--border-subtle)',
flexShrink: 0,
gap: '1rem',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
{t('changelog.dontShowAgain')}
</label>
<button className="btn btn-primary" onClick={handleClose}>
{t('changelog.close')}
</button>
</div>
</div>
</>,
document.body
);
}
+191 -23
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus } from 'lucide-react';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
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 { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlaylistStore } from '../store/playlistStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
@@ -19,6 +20,144 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download';
}
// ── Add-to-Playlist submenu ───────────────────────────────────────
function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [flipLeft, setFlipLeft] = useState(false);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const recentIds = usePlaylistStore((s) => s.recentIds);
useEffect(() => {
getPlaylists().then((all) => {
const sorted = [...all].sort((a, b) => {
const ai = recentIds.indexOf(a.id);
const bi = recentIds.indexOf(b.id);
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
setPlaylists(sorted);
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Flip submenu left if it would overflow the right edge of the viewport
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
}
}, []);
useEffect(() => {
if (creating) newNameRef.current?.focus();
}, [creating]);
const handleAdd = async (pl: SubsonicPlaylist) => {
setAdding(pl.id);
try {
const { songs } = await getPlaylist(pl.id);
const existingIds = new Set(songs.map((s) => s.id));
const newIds = songIds.filter((id) => !existingIds.has(id));
if (newIds.length > 0) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
}
touchPlaylist(pl.id);
} catch {}
setAdding(null);
onDone();
};
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const pl = await createPlaylist(name, songIds);
if (pl?.id) touchPlaylist(pl.id);
} catch {}
onDone();
};
const subStyle: React.CSSProperties = flipLeft
? { right: 'calc(100% + 4px)', left: 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div className="context-submenu" ref={subRef} style={subStyle}>
{/* New Playlist row */}
{!creating ? (
<div
className="context-menu-item context-submenu-new"
onClick={e => { e.stopPropagation(); setCreating(true); }}
>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{playlists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.empty')}</div>
)}
{playlists.map((pl) => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl)}
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
// Same as AddToPlaylistSubmenu but resolves album songs first
function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: () => void }) {
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
useEffect(() => {
getAlbum(albumId).then((data) => {
setResolvedIds(data.songs.map((s) => s.id));
}).catch(() => setResolvedIds([]));
}, [albumId]);
if (resolvedIds === null) {
return (
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
);
}
if (resolvedIds.length === 0) return null;
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
@@ -29,10 +168,14 @@ export default function ContextMenu() {
// Adjusted coordinates to keep menu on screen
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false);
const [playlistSongIds, setPlaylistSongIds] = useState<string[]>([]);
useEffect(() => {
if (contextMenu.isOpen) {
setCoords({ x: contextMenu.x, y: contextMenu.y });
setPlaylistSubmenuOpen(false);
setPlaylistSongIds([]);
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
@@ -64,15 +207,11 @@ export default function ContextMenu() {
const startRadio = async (artistId: string, artistName: string) => {
try {
const similar = await getSimilarSongs2(artistId);
if (similar.length > 0) {
const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
playTrack(radioTracks[0], radioTracks);
}
if (similar.length > 0) {
const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(songToTrack);
playTrack(radioTracks[0], radioTracks);
}
} catch (e) {
console.error('Failed to start radio', e);
}
@@ -129,16 +268,23 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
})}>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(songToTrack);
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div>
)}
@@ -200,6 +346,17 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
onMouseEnter={() => { setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
<AlbumToPlaylistSubmenu albumId={album.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
</>
);
})()}
@@ -236,6 +393,17 @@ export default function ContextMenu() {
})}>
{t('contextMenu.removeFromQueue')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { X, Download } from 'lucide-react';
interface Props {
onConfirm: (since: number) => void;
onClose: () => void;
}
export default function ExportPickerModal({ onConfirm, onClose }: Props) {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
const handleConfirm = () => {
const since = new Date(date + 'T00:00:00').getTime();
onConfirm(since);
};
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 99998,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-subtle)',
borderRadius: '14px',
padding: '28px 32px',
width: '340px',
boxShadow: '0 8px 40px rgba(0,0,0,0.5)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }}>
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: 'var(--text-primary)' }}>
Alben exportieren
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: '4px', display: 'flex' }}
>
<X size={18} />
</button>
</div>
<p style={{ margin: '0 0 16px', fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Alle Alben exportieren, die seit diesem Datum hinzugekommen sind:
</p>
<input
type="date"
value={date}
max={today}
onChange={e => {
setDate(e.target.value);
e.target.blur();
}}
style={{
width: '100%',
padding: '9px 12px',
borderRadius: '8px',
border: '1px solid var(--border-subtle)',
background: 'var(--bg-app)',
color: 'var(--text-primary)',
fontSize: '14px',
boxSizing: 'border-box',
outline: 'none',
colorScheme: 'dark',
}}
/>
<div style={{ display: 'flex', gap: '10px', marginTop: '20px' }}>
<button className="btn btn-surface" onClick={onClose} style={{ flex: 1 }}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handleConfirm}
disabled={!date}
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
>
<Download size={15} />
Exportieren
</button>
</div>
</div>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useEffect, useRef, useState } from 'react';
import { Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getGenres } from '../api/subsonic';
interface GenreFilterBarProps {
selected: string[];
onSelectionChange: (selected: string[]) => void;
}
export default function GenreFilterBar({ selected, onSelectionChange }: GenreFilterBarProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [genres, setGenres] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
getGenres().then(data =>
setGenres(data.map(g => g.value).sort((a, b) => a.localeCompare(b)))
);
}, []);
// close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// sync open state with selection
useEffect(() => {
if (selected.length > 0) setOpen(true);
}, [selected]);
const filteredOptions = genres.filter(
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
);
const add = (genre: string) => {
onSelectionChange([...selected, genre]);
setSearch('');
inputRef.current?.focus();
};
const remove = (genre: string) => {
onSelectionChange(selected.filter(s => s !== genre));
};
const clear = () => {
onSelectionChange([]);
setSearch('');
setOpen(false);
setDropdownOpen(false);
};
const openFilter = () => {
setOpen(true);
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
};
if (!open) {
return (
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Filter size={14} />
{t('common.filterGenre')}
</button>
);
}
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
// relatedTarget is the next focused element; if it's outside our container, handle close
const next = e.relatedTarget as Node | null;
if (containerRef.current && next && containerRef.current.contains(next)) return;
setTimeout(() => {
if (selected.length === 0) {
setOpen(false);
setSearch('');
setDropdownOpen(false);
} else {
setDropdownOpen(false);
}
}, 150);
};
return (
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div className="genre-filter-tagbox">
{selected.map(g => (
<span key={g} className="genre-filter-chip">
{g}
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
<X size={11} />
</button>
</span>
))}
<input
ref={inputRef}
className="genre-filter-input"
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
value={search}
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
onFocus={() => setDropdownOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
remove(selected[selected.length - 1]);
}
}}
/>
{dropdownOpen && filteredOptions.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
{filteredOptions.slice(0, 60).map(g => (
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
{g}
</div>
))}
</div>
)}
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
</div>
)}
</div>
{selected.length > 0 && (
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
<X size={13} />
{t('common.filterClear')}
</button>
)}
</div>
);
}
+28 -14
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
@@ -80,11 +80,28 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const album = albums[activeIdx] ?? null;
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
getAlbum(album.id).then(data => {
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
});
}, [album?.id]);
// Resolve background URL via cache
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
// Resolve cover thumbnail via cache
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
@@ -99,7 +116,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
<HeroBg url={resolvedBgUrl} />
<HeroBg url={stableBgUrl.current} />
<div className="hero-overlay" aria-hidden="true" />
{/* key causes re-mount → animate-fade-in triggers on each album change */}
@@ -120,6 +137,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
@@ -133,18 +151,14 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
</button>
<button
className="btn btn-surface"
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
usePlayerStore.getState().enqueue(tracks);
} catch (_) { }
}}
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) { }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')}
>
+20 -10
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music } from 'lucide-react';
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
@@ -57,10 +57,10 @@ export default function LiveSearch() {
const flatItems = results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
setOpen(false); setQuery('');
}}))),
...(results.songs.map(s => ({ id: s.id, action: () => {
playTrack(songToTrack(s));
setOpen(false); setQuery('');
}}))),
] : [];
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -116,6 +116,16 @@ export default function LiveSearch() {
×
</button>
)}
<button
className="live-search-adv-btn"
type="button"
onClick={() => navigate(query.trim() ? `/search/advanced?q=${encodeURIComponent(query.trim())}` : '/search/advanced')}
data-tooltip={t('search.advanced')}
data-tooltip-pos="bottom"
aria-label={t('search.advanced')}
>
<SlidersHorizontal size={14} />
</button>
</div>
{open && (
@@ -175,10 +185,10 @@ export default function LiveSearch() {
const i = idx++;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
setOpen(false); setQuery('');
}}
onClick={() => {
playTrack(songToTrack(s));
setOpen(false); setQuery('');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div>
<div>
+40 -17
View File
@@ -8,14 +8,25 @@ interface Props {
currentTrack: Track | null;
}
interface CachedLyrics {
syncedLines: LrcLine[] | null;
plainLyrics: string | null;
notFound: boolean;
}
// Session-level cache — survives tab switches (component unmount/remount).
// Cleared implicitly when the app restarts.
const lyricsCache = new Map<string, CachedLyrics>();
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
const [loading, setLoading] = useState(!cached && !!currentTrack);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
@@ -24,7 +35,20 @@ export default function LyricsPane({ currentTrack }: Props) {
const prevActive = useRef(-1);
useEffect(() => {
if (!currentTrack || currentTrack.id === fetchedFor) return;
if (!currentTrack) return;
// Serve from cache if available
const hit = lyricsCache.get(currentTrack.id);
if (hit) {
setSyncedLines(hit.syncedLines);
setPlainLyrics(hit.plainLyrics);
setNotFound(hit.notFound);
setLoading(false);
lineRefs.current = [];
prevActive.current = -1;
return;
}
let cancelled = false;
setSyncedLines(null);
setPlainLyrics(null);
@@ -41,27 +65,26 @@ export default function LyricsPane({ currentTrack }: Props) {
).then(result => {
if (cancelled) return;
setLoading(false);
setFetchedFor(currentTrack.id);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setNotFound(true);
return;
}
if (result.syncedLyrics) {
const lines = parseLrc(result.syncedLyrics);
setSyncedLines(lines.length > 0 ? lines : null);
}
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
const synced = lines && lines.length > 0 ? lines : null;
lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false });
setSyncedLines(synced);
setPlainLyrics(result.plainLyrics);
}).catch(() => {
if (!cancelled) { setLoading(false); setNotFound(true); }
if (!cancelled) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setLoading(false);
setNotFound(true);
}
});
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset when track changes
useEffect(() => {
setFetchedFor(null);
}, [currentTrack?.id]);
const activeIdx = hasSynced
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
+1 -1
View File
@@ -146,7 +146,7 @@ export default function NowPlayingDropdown() {
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
<User size={10} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
{stream.minutesAgo > 0 && <span> {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { WifiOff, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
onRetry: () => void;
isChecking: boolean;
}
export default function OfflineBanner({ onRetry, isChecking }: Props) {
const { t } = useTranslation();
return (
<div className="offline-banner">
<WifiOff size={14} />
<span>{t('connection.offlineModeBanner')}</span>
<button
className="offline-banner-retry"
onClick={onRetry}
disabled={isChecking}
>
<RefreshCw size={12} className={isChecking ? 'spin' : ''} />
{t('connection.retry')}
</button>
</div>
);
}
+2 -2
View File
@@ -9,8 +9,8 @@ export default function PsysonicLogo({ className, style }: Props) {
return (
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id="psysonicGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--ctp-blue)" />
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
</linearGradient>
</defs><g
id="layer1"
+245 -178
View File
@@ -1,12 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
@@ -59,10 +60,12 @@ function SavePlaylistModal({ onClose, onSave }: { onClose: () => void, onSave: (
);
}
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string) => void }) {
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string, name: string, mode: 'replace' | 'append') => void }) {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
const fetchPlaylists = () => {
setLoading(true);
@@ -80,28 +83,45 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
}, []);
const handleDelete = async (id: string, name: string) => {
if (confirm(t('queue.deleteConfirm', { name }))) {
await deletePlaylist(id);
fetchPlaylists();
}
setConfirmDelete({ id, name });
};
const confirmDeletePlaylist = async () => {
if (!confirmDelete) return;
await deletePlaylist(confirmDelete.id);
setConfirmDelete(null);
fetchPlaylists();
};
return (
<>
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '560px', width: '90vw' }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
{!loading && playlists.length > 0 && (
<input
type="text"
className="live-search-field"
placeholder={t('queue.filterPlaylists')}
value={filter}
onChange={e => setFilter(e.target.value)}
autoFocus
style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }}
/>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
) : playlists.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
{playlists.map(p => (
{playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
<span style={{ fontWeight: 500 }} className="truncate" data-tooltip={p.name}>{p.name}</span>
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
<button className="nav-btn" onClick={() => onLoad(p.id)} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => onLoad(p.id, p.name, 'replace')} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => onLoad(p.id, p.name, 'append')} data-tooltip={t('queue.appendToQueue')} style={{ width: '28px', height: '28px', background: 'transparent' }}><ListPlus size={14} /></button>
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
</div>
</div>
@@ -110,13 +130,28 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
)}
</div>
</div>
{confirmDelete && (
<div className="modal-overlay" onClick={() => setConfirmDelete(null)} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '360px' }}>
<button className="modal-close" onClick={() => setConfirmDelete(null)}><X size={18} /></button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{t('queue.delete')}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('queue.deleteConfirm', { name: confirmDelete.name })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>{t('queue.cancel')}</button>
<button className="btn btn-primary" style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={confirmDeletePlaylist}>
{t('queue.delete')}
</button>
</div>
</div>
</div>
)}
</>
);
}
// Module-level fallback for fromIdx — survives the dragend-before-drop race on
// macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2.
let _dragFromIdx: number | null = null;
export default function QueuePanel() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -132,6 +167,7 @@ export default function QueuePanel() {
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
const enqueueAt = usePlayerStore(s => s.enqueueAt);
const contextMenu = usePlayerStore(s => s.contextMenu);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
@@ -162,22 +198,85 @@ export default function QueuePanel() {
return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]);
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false);
// Refs mirror state so drop handler always reads fresh values even when
// macOS WKWebView fires dragend before drop (spec violation).
const draggedIdxRef = useRef<number | null>(null);
const dragOverIdxRef = useRef<number | null>(null);
// Tracks which queue index is being psy-dragged for opacity visual feedback
const psyDragFromIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
useEffect(() => {
if (!isPsyDragging) {
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}, [isPsyDragging]);
const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
// ── Mouse-event DnD: listen for psy-drop custom events ─────────
useEffect(() => {
const aside = asideRef.current;
if (!aside) return;
const onPsyDrop = async (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsedData: any = null;
try { parsedData = JSON.parse(detail.data); } catch { return; }
const dropTarget = externalDropTargetRef.current;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length;
if (parsedData.type === 'queue_reorder') {
const fromIdx: number = parsedData.index;
psyDragFromIdxRef.current = null;
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
} else if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueueAt(tracks, insertIdx);
}
};
aside.addEventListener('psy-drop', onPsyDrop);
return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]);
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = () => {
const handleSave = async () => {
if (queue.length === 0) return;
setSaveModalOpen(true);
if (activePlaylist) {
setSaveState('saving');
try {
await updatePlaylist(activePlaylist.id, queue.map(t => t.id));
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500);
} catch (e) {
console.error('Failed to update playlist', e);
setSaveState('idle');
}
} else {
setSaveModalOpen(true);
}
};
const handleLoad = () => {
@@ -186,107 +285,41 @@ export default function QueuePanel() {
const handleClear = () => {
clearQueue();
setActivePlaylist(null);
};
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
_dragFromIdx = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
};
const onDragEnd = () => {
setDraggedIdx(null);
setDragOverIdx(null);
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
// _dragFromIdx intentionally NOT cleared here — drop fires after dragend on
// macOS WKWebView, so we need the value to survive into onDropQueue.
// It is cleared in onDropQueue after use instead.
};
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Clear visual state immediately
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
let parsedData: any = null;
try {
const raw = e.dataTransfer.getData('text/plain');
if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ }
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
// fromIdx: prefer dataTransfer value; fall back to module-level var for
// Windows WebView2 where getData() can return '' in the drop handler.
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
_dragFromIdx = null;
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
let toIdx = queue.length;
if (queueListRef.current) {
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
toIdx = parseInt(items[i].dataset.queueIdx!);
break;
}
}
}
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
return;
}
// External drop (song / album dragged from elsewhere in the app)
_dragFromIdx = null;
if (!parsedData) return;
if (parsedData.type === 'song') {
enqueue([parsedData.track]);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
}
};
return (
<aside
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={onDropQueue}
style={{
ref={asideRef}
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const before = e.clientY < rect.top + rect.height / 2;
const idx = parseInt(items[i].dataset.queueIdx!);
const target = { idx, before };
externalDropTargetRef.current = target;
setExternalDropTarget(target);
found = true;
break;
}
}
if (!found) {
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
>
<div className="queue-header">
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
@@ -315,40 +348,51 @@ export default function QueuePanel() {
);
})()}
</div>
{activePlaylist && (
<div className="truncate" style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ListMusic size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{activePlaylist.name}</span>
</div>
)}
</div>
</div>
{currentTrack && (
<div className="queue-current-track">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
{(currentTrack.bitRate || currentTrack.suffix) && (
<div className="queue-current-tech">
{currentTrack.bitRate && currentTrack.suffix
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
: currentTrack.suffix?.toUpperCase() ?? ''}
</div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
<div className="queue-current-tech">
{[
currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
].filter(Boolean).join(' · ')}
</div>
)}
<div className="queue-current-track-body">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
</div>
</div>
</div>
)}
@@ -358,8 +402,14 @@ export default function QueuePanel() {
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
<button className="queue-round-btn" onClick={handleSave} data-tooltip={t('queue.savePlaylist')} aria-label={t('queue.savePlaylist')}>
<Save size={13} />
<button
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
onClick={handleSave}
disabled={saveState === 'saving'}
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
aria-label={t('queue.savePlaylist')}
>
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
</button>
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
@@ -370,7 +420,7 @@ export default function QueuePanel() {
<div className="queue-toolbar-sep" />
<button
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => setGaplessEnabled(!gaplessEnabled)}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
@@ -385,6 +435,7 @@ export default function QueuePanel() {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
@@ -431,15 +482,12 @@ export default function QueuePanel() {
) : (
queue.map((track, idx) => {
const isPlaying = idx === queueIndex;
const isDragging = draggedIdx === idx;
const isDragOver = dragOverIdx === idx;
// Highlight above or below depending on index direction
let dragStyle: React.CSSProperties = {};
if (isDragging) {
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isDragOver && draggedIdx !== null) {
if (draggedIdx > idx) {
} else if (isPsyDragging && externalDropTarget?.idx === idx) {
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
@@ -456,11 +504,26 @@ export default function QueuePanel() {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
}}
draggable
onDragStart={(e) => onDragStart(e, idx)}
onDragEnter={(e) => onDragEnterItem(e)}
onDragOver={(e) => onDragOverItem(e, idx)}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = idx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: idx }), label: track.title }, me.clientX, me.clientY);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={dragStyle}
>
<div className="queue-item-info">
@@ -502,39 +565,43 @@ export default function QueuePanel() {
</div>
{saveModalOpen && (
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
try {
await createPlaylist(name, queue.map(t => t.id));
setSaveModalOpen(false);
const playlists = await getPlaylists();
const created = playlists.find(p => p.name === name);
if (created) setActivePlaylist({ id: created.id, name: created.name });
setSaveModalOpen(false);
} catch (e) {
console.error('Failed to save playlist', e);
}
}}
}}
/>
)}
{loadModalOpen && (
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async (id) => {
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async (id, name, mode) => {
try {
const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
const tracks: Track[] = data.songs.map(songToTrack);
if (tracks.length > 0) {
clearQueue();
playTrack(tracks[0], tracks);
if (mode === 'append') {
enqueue(tracks);
} else {
clearQueue();
playTrack(tracks[0], tracks);
}
}
setLoadModalOpen(false);
setActivePlaylist({ id, name });
setLoadModalOpen(false);
} catch (e) {
console.error('Failed to load playlist', e);
}
}}
}}
/>
)}
</aside>
+37 -4
View File
@@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags, ListMusic
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -17,9 +19,10 @@ const navItems = [
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' },
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
];
function isNewer(latest: string, current: string): boolean {
@@ -61,7 +64,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
export default function Sidebar({
isCollapsed = false,
toggleCollapse
toggleCollapse,
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
@@ -69,6 +72,11 @@ export default function Sidebar({
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineStore(s => s.jobs);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
@@ -143,6 +151,18 @@ export default function Sidebar({
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
</NavLink>
{hasOfflineContent && (
<NavLink
to="/offline"
className={({ isActive }) => `nav-link nav-link-offline ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.offlineLibrary') : undefined}
data-tooltip-pos="bottom"
>
<HardDriveDownload size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.offlineLibrary')}</span>}
</NavLink>
)}
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
@@ -172,6 +192,19 @@ export default function Sidebar({
<Settings size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
</NavLink>
{activeJobs.length > 0 && (
<div
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobs.length }) : undefined}
data-tooltip-pos="right"
>
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</span>
)}
</div>
)}
</nav>
</aside>
);
+50 -19
View File
@@ -13,24 +13,29 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Games',
themes: [
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#06060f', card: '#0a0a1a', accent: '#FCEE0A' },
{ id: 'tetrastack', label: 'TetraStack', bg: '#060614', card: '#0c0c20', accent: '#00f0f0' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
],
},
{
group: 'Movies',
themes: [
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'order-of-the-phoenix', label: 'Order of the Phoenix', bg: '#181818', card: '#0a0a0a', accent: '#e63900' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'dune', label: 'Dune', bg: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 't-800', label: 'T-800', bg: '#140e0e', card: '#1a0a0a', accent: '#ff2000' },
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
],
},
{
@@ -55,11 +60,19 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Operating Systems',
themes: [
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'ubuntu-ambiance', label: 'Ubuntu', bg: '#f4efea', card: '#3d1f3d', accent: '#e95420' },
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
{ id: 'wista', label: 'Wista', bg: '#eef3fc', card: '#0e1e3e', accent: '#1565c8' },
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
{ id: 'w10', label: 'W10', bg: '#f3f3f3', card: '#ffffff', accent: '#0078d4' },
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
],
},
{
@@ -73,23 +86,41 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
],
},
{
group: 'Psysonic Themes — Mediaplayer',
group: 'Mediaplayer',
themes: [
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'winmedplayer', label: 'WinMedPlayer', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#d4cc46' },
],
},
{
group: 'Series',
themes: [
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#121820', card: '#05070a', accent: '#70a1ff' },
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#100c08', card: '#090c10', accent: '#c41e1e' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#1a1d1a', card: '#0a0c0a', accent: '#3fe0ff' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
{ id: 'north-park', label: 'North Park', bg: '#F5F1E8', card: '#FFFFFF', accent: '#FF8C00' },
],
},
{
group: 'Famous Albums',
themes: [
{ id: 'dark-side-of-the-moon', label: 'Dark Side of the Moon (inspired)', bg: '#050505', card: '#0D0D0D', accent: '#9B30FF' },
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
],
},
{
group: 'Social Media',
themes: [
{ id: 'insta', label: 'Insta', bg: '#121212', card: '#000000', accent: '#E1306C' },
{ id: 'readit', label: 'ReadIt', bg: '#030303', card: '#1A1A1B', accent: '#FF4500' },
{ id: 'the-book', label: 'The Book', bg: '#F0F2F5', card: '#FFFFFF', accent: '#1877F2' },
],
},
];
+37 -13
View File
@@ -1,6 +1,11 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
const BAR_COUNT = 500;
function hashStr(str: string): number {
@@ -59,9 +64,9 @@ function drawWaveform(
ctx.clearRect(0, 0, w, h);
const style = getComputedStyle(document.documentElement);
const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
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';
if (!heights) {
ctx.globalAlpha = 0.3;
@@ -125,9 +130,12 @@ export default function WaveformSeek({ trackId }: Props) {
const bufferedRef = useRef(0);
const isDragging = useRef(false);
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);
progressRef.current = progress;
bufferedRef.current = buffered;
@@ -175,14 +183,30 @@ export default function WaveformSeek({ trackId }: Props) {
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
/>
<div style={{ position: 'relative', width: '100%' }}>
{hoverPct !== null && duration > 0 && (
<span
className="player-volume-pct"
style={{ left: `${hoverPct * 100}%` }}
>
{fmt(hoverPct * duration)}
</span>
)}
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
const rect = e.currentTarget.getBoundingClientRect();
setHoverPct(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseLeave={() => setHoverPct(null)}
/>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Mouse-event-based Drag & Drop system.
*
* Replaces the HTML5 Drag & Drop API for cross-component drags (song queue,
* album queue) because WebKitGTK on Linux always shows a "forbidden" cursor
* during native HTML5 DnD and there is no way to fix it at the GTK level
* without breaking DnD entirely.
*
* This system uses mousedown / mousemove / mouseup which keeps cursor control
* in CSS and avoids the native DnD subsystem completely.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
// ── Types ─────────────────────────────────────────────────────────
export interface DragPayload {
/** Serialised JSON identical to what was previously in dataTransfer */
data: string;
/** Label shown on the ghost element */
label: string;
/** Optional cover URL for the ghost */
coverUrl?: string;
}
interface DragState {
payload: DragPayload | null;
position: { x: number; y: number };
}
interface DragDropContextValue {
/** Begin a drag. Called from mousedown (after threshold). */
startDrag: (payload: DragPayload, x: number, y: number) => void;
/** Current drag payload (null when idle). */
payload: DragPayload | null;
/** Whether a drag is in progress. */
isDragging: boolean;
}
const Ctx = createContext<DragDropContextValue>({
startDrag: () => {},
payload: null,
isDragging: false,
});
export const useDragDrop = () => useContext(Ctx);
// ── Ghost overlay ─────────────────────────────────────────────────
function DragGhost({ state }: { state: DragState }) {
if (!state.payload) return null;
const { label, coverUrl } = state.payload;
return createPortal(
<div
style={{
position: 'fixed',
left: state.position.x + 12,
top: state.position.y - 20,
pointerEvents: 'none',
zIndex: 99999,
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--bg-card, #1e1e2e)',
border: '1px solid var(--border, rgba(255,255,255,0.1))',
borderRadius: 8,
padding: '6px 12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
color: 'var(--text-primary, #fff)',
fontSize: 13,
fontWeight: 500,
maxWidth: 280,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
opacity: 0.95,
userSelect: 'none',
}}
>
{coverUrl && (
<img
src={coverUrl}
alt=""
style={{
width: 28,
height: 28,
borderRadius: 4,
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{label}
</span>
</div>,
document.body,
);
}
// ── Provider ──────────────────────────────────────────────────────
export function DragDropProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<DragState>({
payload: null,
position: { x: 0, y: 0 },
});
const stateRef = useRef(state);
stateRef.current = state;
const startDrag = useCallback(
(payload: DragPayload, x: number, y: number) => {
// Clear any text selection the browser may have started during the
// threshold detection phase (mousedown → mousemove before startDrag).
window.getSelection()?.removeAllRanges();
setState({ payload, position: { x, y } });
},
[],
);
// Global mousemove + mouseup listeners (only while dragging)
useEffect(() => {
if (!state.payload) return;
const onMove = (e: MouseEvent) => {
// preventDefault stops the browser from treating the mouse movement as
// a text-selection drag, which causes element highlighting and
// horizontal auto-scroll in grid containers.
e.preventDefault();
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
};
const onUp = () => {
// Clear any residual selection (from the pre-threshold phase).
window.getSelection()?.removeAllRanges();
// Dispatch a custom event so drop targets can react.
// The payload is in `detail`.
const evt = new CustomEvent('psy-drop', {
bubbles: true,
detail: stateRef.current.payload,
});
// Find element under cursor
const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt);
setState({ payload: null, position: { x: 0, y: 0 } });
};
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
// Add a class so CSS can show grab cursor and suppress selection
document.body.classList.add('psy-dragging');
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('psy-dragging');
};
}, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps
const ctxValue: DragDropContextValue = {
startDrag,
payload: state.payload,
isDragging: state.payload !== null,
};
return (
<Ctx.Provider value={ctxValue}>
{children}
<DragGhost state={state} />
</Ctx.Provider>
);
}
// ── useDragSource hook ────────────────────────────────────────────
const DRAG_THRESHOLD = 5; // px before drag starts
/**
* Returns an onMouseDown handler for a draggable element.
* Usage: <div {...useDragSource(payload)} />
*/
export function useDragSource(getPayload: () => DragPayload) {
const { startDrag } = useDragDrop();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const payloadRef = useRef(getPayload);
payloadRef.current = getPayload;
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left-click
if (e.button !== 0) return;
const startX = e.clientX;
const startY = e.clientY;
startPosRef.current = { x: startX, y: startY };
const onMove = (me: MouseEvent) => {
if (!startPosRef.current) return;
const dx = me.clientX - startX;
const dy = me.clientY - startY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
startPosRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
startDrag(payloadRef.current(), me.clientX, me.clientY);
}
};
const onUp = () => {
startPosRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[startDrag],
);
return { onMouseDown };
}
+1095 -145
View File
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Play, SlidersHorizontal } from 'lucide-react';
import {
search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs,
SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import CustomSelect from '../components/CustomSelect';
import { useDragDrop } from '../contexts/DragDropContext';
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
interface SearchOpts {
query: string;
genre: string;
yearFrom: string;
yearTo: string;
resultType: ResultType;
}
interface Results {
artists: SubsonicArtist[];
albums: SubsonicAlbum[];
songs: SubsonicSong[];
}
export default function AdvancedSearch() {
const { t } = useTranslation();
const [params] = useSearchParams();
const navigate = useNavigate();
const psyDrag = useDragDrop();
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const [query, setQuery] = useState(params.get('q') ?? '');
const [genre, setGenre] = useState('');
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const [resultType, setResultType] = useState<ResultType>('all');
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [results, setResults] = useState<Results | null>(null);
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [genreNote, setGenreNote] = useState(false);
const runSearch = async (opts: SearchOpts) => {
setLoading(true);
setHasSearched(true);
setGenreNote(false);
const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts;
const from = yf ? parseInt(yf) : null;
const to = yt ? parseInt(yt) : null;
let artists: SubsonicArtist[] = [];
let albums: SubsonicAlbum[] = [];
let songs: SubsonicSong[] = [];
try {
if (q.trim()) {
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: 100 });
artists = r.artists;
albums = r.albums;
songs = r.songs;
if (g) {
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
}
if (from !== null) {
albums = albums.filter(a => !a.year || a.year >= from);
songs = songs.filter(s => !s.year || s.year >= from);
}
if (to !== null) {
albums = albums.filter(a => !a.year || a.year <= to);
songs = songs.filter(s => !s.year || s.year <= to);
}
} else if (g) {
const [albumRes, songRes] = await Promise.all([
rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50),
rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g),
]);
albums = albumRes as SubsonicAlbum[];
songs = songRes as SubsonicSong[];
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
if (songs.length > 0) setGenreNote(true);
} else if (from !== null || to !== null) {
const fromYear = from ?? 1900;
const toYear = to ?? new Date().getFullYear();
albums = await getAlbumList('byYear', 100, 0, { fromYear, toYear });
}
setResults({
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
});
} catch {
setResults({ artists: [], albums: [], songs: [] });
}
setLoading(false);
};
useEffect(() => {
getGenres().then(data =>
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
).catch(() => {});
const q = params.get('q') ?? '';
if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
}, []);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
runSearch({ query, genre, yearFrom, yearTo, resultType });
};
const total = results
? results.artists.length + results.albums.length + results.songs.length
: 0;
const typeOptions: { id: ResultType; label: string }[] = [
{ id: 'all', label: t('search.advancedAll') },
{ id: 'artists', label: t('search.artists') },
{ id: 'albums', label: t('search.albums') },
{ id: 'songs', label: t('search.songs') },
];
const genreSelectOptions = [
{ value: '', label: t('search.advancedAllGenres') },
...genres.map(g => ({ value: g.value, label: g.value })),
];
return (
<div className="content-body animate-fade-in">
<div style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<SlidersHorizontal size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
{t('search.advanced')}
</h1>
</div>
{/* ── Filter panel ──────────────────────────────────────── */}
<form onSubmit={handleSubmit}>
<div className="settings-card" style={{ padding: '1.25rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
{/* Row 1: Search term */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedSearchTerm')}
</span>
<input
className="input"
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('search.advancedSearchPlaceholder')}
style={{ flex: 1 }}
autoFocus
/>
</div>
{/* Row 2: Genre + Year */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedGenre')}
</span>
<div style={{ minWidth: 180 }}>
<CustomSelect
value={genre}
options={genreSelectOptions}
onChange={setGenre}
/>
</div>
<span style={{ fontSize: 13, color: 'var(--text-muted)', marginLeft: '0.75rem', flexShrink: 0 }}>
{t('search.advancedYear')}
</span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
placeholder={t('search.advancedYearFrom')}
style={{ width: 76 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
placeholder={t('search.advancedYearTo')}
style={{ width: 76 }}
/>
</div>
{/* Row 3: Result type + Search button */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
{typeOptions.map(opt => (
<button
key={opt.id}
type="button"
className={`btn ${resultType === opt.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => setResultType(opt.id)}
>
{opt.label}
</button>
))}
</div>
<button
className="btn btn-primary"
type="submit"
disabled={loading}
style={{ minWidth: 100 }}
>
{loading
? <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
: t('search.advancedSearch')
}
</button>
</div>
</div>
</div>
</form>
{/* ── Results ───────────────────────────────────────────── */}
{!hasSearched ? (
<div className="empty-state" style={{ opacity: 0.6 }}>
{t('search.advancedEmpty')}
</div>
) : loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : total === 0 ? (
<div className="empty-state">{t('search.advancedNoResults')}</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{results && results.artists.length > 0 && (
<ArtistRow
title={`${t('search.artists')} (${results.artists.length})`}
artists={results.artists}
/>
)}
{results && results.albums.length > 0 && (
<AlbumRow
title={`${t('search.albums')} (${results.albums.length})`}
albums={results.albums}
/>
)}
{results && results.songs.length > 0 && (
<section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>
{t('search.songs')} ({results.songs.length})
{genreNote && (
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text-muted)', marginLeft: '0.75rem' }}>
{t('search.advancedGenreNote')}
</span>
)}
</h2>
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}>
<span />
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{results.songs.map(song => {
const track = songToTrack(song);
return (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
role="row"
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); playTrack(track, results.songs.map(songToTrack)); }}
>
<Play size={13} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className="track-artist"
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>
{song.artist}
</span>
</div>
<div className="track-info">
<span
className="track-title"
style={{ fontSize: '0.85rem', color: 'var(--subtext0)', cursor: 'pointer' }}
onClick={() => navigate(`/album/${song.albumId}`)}
>
{song.album}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{song.genre ?? '—'}
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</span>
</div>
);
})}
</div>
</section>
)}
</div>
)}
</div>
);
}
+90 -31
View File
@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useOfflineStore } from '../store/offlineStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import AlbumCard from '../components/AlbumCard';
@@ -42,7 +44,30 @@ export default function AlbumDetail() {
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
const [offlineStorageFull, setOfflineStorageFull] = useState(false);
const { downloadAlbum, deleteAlbum } = useOfflineStore();
const offlineTracks = useOfflineStore(s => s.tracks);
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineJobs = useOfflineStore(s => s.jobs);
const serverId = auth.activeServerId ?? '';
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
if (!album) return 'none';
const meta = offlineAlbums[`${serverId}:${album.album.id}`];
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!offlineTracks[`${serverId}:${tid}`]);
if (isDownloaded) return 'cached';
const isDownloading = offlineJobs.some(j => j.albumId === album.album.id && (j.status === 'queued' || j.status === 'downloading'));
return isDownloading ? 'downloading' : 'none';
})();
const offlineProgress = (() => {
if (!album) return null;
const albumJobs = offlineJobs.filter(j => j.albumId === album.album.id);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
})();
useEffect(() => {
if (!id) return;
@@ -64,34 +89,33 @@ export default function AlbumDetail() {
}).catch(() => setLoading(false));
}, [id]);
const handlePlayAll = () => {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
};
const handlePlayAll = () => {
if (!album) return;
const albumGenre = album.album.genre;
const tracks = album.songs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
if (tracks[0]) playTrack(tracks[0], tracks);
};
const handleEnqueueAll = () => {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
};
const handleEnqueueAll = () => {
if (!album) return;
const albumGenre = album.album.genre;
const tracks = album.songs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
enqueue(tracks);
};
const handlePlaySong = (song: SubsonicSong) => {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
playTrack(track, [track]);
};
const handlePlaySong = (song: SubsonicSong) => {
const track = songToTrack(song);
if (!track.genre && album?.album.genre) track.genre = album.album.genre;
playTrack(track, [track]);
};
const handleRate = async (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
@@ -180,6 +204,27 @@ export default function AlbumDetail() {
}
};
const handleCacheOffline = useCallback(async () => {
if (!album) return;
const maxBytes = auth.maxCacheMb * 1024 * 1024;
try {
const usedBytes = await invoke<number>('get_offline_cache_size');
if (usedBytes >= maxBytes) {
setOfflineStorageFull(true);
return;
}
} catch {
// If we can't check, proceed anyway
}
setOfflineStorageFull(false);
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
}, [album, auth.maxCacheMb, downloadAlbum, serverId]);
const handleRemoveOffline = () => {
if (!album) return;
deleteAlbum(album.album.id, serverId);
};
// Hooks must be called unconditionally — derive from nullable album state
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
@@ -209,15 +254,29 @@ export default function AlbumDetail() {
onEnqueueAll={handleEnqueueAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={offlineStatus}
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
/>
{offlineStorageFull && (
<div className="offline-storage-full-banner" role="alert">
<span>{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}</span>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => navigate('/offline')}>
{t('albumDetail.offlineStorageGoToLibrary')}
</button>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => navigate('/settings', { state: { tab: 'library' } })}>
{t('albumDetail.offlineStorageGoToSettings')}
</button>
<button className="offline-storage-full-dismiss" onClick={() => setOfflineStorageFull(false)} aria-label="Dismiss">×</button>
</div>
)}
<AlbumTrackList
songs={songs}
hasVariousArtists={hasVariousArtists}
currentTrack={currentTrack}
isPlaying={isPlaying}
hoveredSongId={hoveredSongId}
setHoveredSongId={setHoveredSongId}
ratings={ratings}
starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
+45 -21
View File
@@ -1,10 +1,19 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
const PAGE_SIZE = 30;
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
return results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
}
export default function Albums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -12,9 +21,9 @@ export default function Albums() {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const PAGE_SIZE = 30;
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
setLoading(true);
@@ -28,27 +37,40 @@ export default function Albums() {
}
}, []);
useEffect(() => { setPage(0); load(sort, 0); }, [sort, load]);
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
setLoading(true);
try {
const data = await fetchByGenres(genres);
const sorted = [...data].sort((a, b) =>
sortType === 'alphabeticalByArtist'
? a.artist.localeCompare(b.artist)
: a.name.localeCompare(b.name)
);
setAlbums(sorted);
setHasMore(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres, sort);
else { setPage(0); load(sort, 0); }
}, [sort, filtered, selectedGenres, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
if (loading || !hasMore || filtered) return;
const next = page + 1;
setPage(next);
load(sort, next * PAGE_SIZE, true);
}, [loading, hasMore, page, sort, load]);
}, [loading, hasMore, page, sort, load, filtered]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore();
}
},
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore]);
@@ -59,9 +81,9 @@ export default function Albums() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 className="page-title">{t('albums.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<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>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map(o => (
<button
key={o.value}
@@ -72,6 +94,7 @@ export default function Albums() {
{o.label}
</button>
))}
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
</div>
@@ -84,10 +107,11 @@ export default function Albums() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
</div>
+22 -24
View File
@@ -6,7 +6,7 @@ import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
@@ -330,22 +330,19 @@ export default function ArtistDetail() {
<div>{t('artistDetail.trackAlbum')}</div>
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
</div>
{topSongs.map((song, idx) => (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
onDoubleClick={() => playTrack(song, topSongs)}
onContextMenu={(e) => {
e.preventDefault();
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
};
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
{topSongs.map((song, idx) => {
const track = songToTrack(song);
return (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{song.coverArt && (
@@ -365,13 +362,14 @@ export default function ArtistDetail() {
{song.album}
</div>
<div className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</div>
</div>
))}
</div>
</>
)}
{formatDuration(song.duration)}
</div>
</div>
);
})}
</div>
</>
)}
{/* Similar Artists (Last.fm) */}
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
+51 -19
View File
@@ -2,10 +2,12 @@ import React, { useEffect, useState } from 'react';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, ListPlus } from 'lucide-react';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { ListPlus, Play, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
export default function Favorites() {
const { t } = useTranslation();
@@ -15,6 +17,12 @@ export default function Favorites() {
const [loading, setLoading] = useState(true);
const { playTrack, enqueue } = usePlayerStore();
const psyDrag = useDragDrop();
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
setSongs(prev => prev.filter(s => s.id !== id));
}
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
@@ -61,14 +69,20 @@ export default function Favorites() {
<section className="album-row-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button
className="btn btn-primary"
onClick={() => {
const tracks = songs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
<Play size={15} />
{t('favorites.playAll')}
</button>
<button
className="btn btn-surface"
onClick={() => {
const tracks = songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
const tracks = songs.map(songToTrack);
enqueue(tracks);
}}
>
@@ -77,32 +91,40 @@ export default function Favorites() {
</button>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header tracklist-va">
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div />
</div>
{songs.map((song, i) => {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
};
const track = songToTrack(song);
return (
<div
key={song.id}
className="track-row track-row-va"
onDoubleClick={() => playTrack(song, songs)}
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
<div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
{i + 1}
</div>
<div className="track-info">
@@ -118,6 +140,16 @@ export default function Favorites() {
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button
className="btn-icon fav-remove-btn"
data-tooltip={t('favorites.removeSong')}
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
aria-label={t('favorites.removeSong')}
>
<X size={14} />
</button>
</div>
</div>
);
})}
+86
View File
@@ -0,0 +1,86 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3 } from 'lucide-react';
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
const PAGE_SIZE = 50;
export default function GenreDetail() {
const { name } = useParams<{ name: string }>();
const genre = decodeURIComponent(name ?? '');
const { t } = useTranslation();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
useEffect(() => {
setAlbums([]);
setOffset(0);
setHasMore(true);
setLoading(true);
getAlbumsByGenre(genre, PAGE_SIZE, 0)
.then(data => {
setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
setOffset(PAGE_SIZE);
})
.finally(() => setLoading(false));
}, [genre]);
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
getAlbumsByGenre(genre, PAGE_SIZE, offset)
.then(data => {
setAlbums(prev => [...prev, ...data]);
setHasMore(data.length === PAGE_SIZE);
setOffset(prev => prev + PAGE_SIZE);
})
.finally(() => setLoadingMore(false));
}, [genre, offset, loadingMore, hasMore]);
return (
<div className="content-body animate-fade-in">
<button
className="btn btn-ghost"
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{!loading && albums.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
</span>
)}
</div>
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
{albums.length > 0 && (
<div className="album-grid-wrap">
{albums.map(album => <AlbumCard key={album.id} album={album} />)}
</div>
)}
{hasMore && !loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
</button>
</div>
)}
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
Tags, type LucideIcon,
} from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic';
function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase();
if (/ambient|drone|new age/.test(n)) return Cloud;
if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap;
if (/rock/.test(n)) return Radio;
if (/jazz/.test(n)) return Music2;
if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music;
if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu;
if (/hip.?hop|rap/.test(n)) return Mic;
if (/pop/.test(n)) return Star;
if (/folk|country|bluegrass|americana/.test(n)) return Leaf;
if (/blues/.test(n)) return Music2;
if (/soul|r.?b|funk|gospel/.test(n)) return Heart;
if (/reggae|ska|dub/.test(n)) return Sun;
if (/punk/.test(n)) return Flame;
if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film;
if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe;
if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen;
if (/podcast/.test(n)) return Podcast;
return Headphones;
}
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
'var(--ctp-blue)', 'var(--ctp-lavender)',
];
function genreColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return CTP_COLORS[h % CTP_COLORS.length];
}
const SCROLL_KEY = 'genres-scroll';
export default function Genres() {
const { t } = useTranslation();
const navigate = useNavigate();
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getGenres()
.then(data => {
const sorted = [...data].sort((a, b) => b.albumCount - a.albumCount);
setGenres(sorted);
})
.finally(() => setLoading(false));
}, []);
// Restore scroll position after genres are rendered
useEffect(() => {
if (loading || genres.length === 0) return;
const saved = sessionStorage.getItem(SCROLL_KEY);
if (!saved) return;
const pos = parseInt(saved, 10);
sessionStorage.removeItem(SCROLL_KEY);
requestAnimationFrame(() => {
if (containerRef.current) containerRef.current.scrollTop = pos;
});
}, [loading, genres.length]);
const handleGenreClick = (genreValue: string) => {
if (containerRef.current) {
sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop));
}
navigate(`/genres/${encodeURIComponent(genreValue)}`);
};
return (
<div ref={containerRef} className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
{!loading && genres.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Tags size={14} style={{ color: 'var(--accent)' }} />
{genres.length} {t('genres.genreCount')}
</span>
)}
</div>
{loading && <p className="loading-text">{t('genres.loading')}</p>}
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
{!loading && genres.length > 0 && (
<div className="album-grid-wrap">
{genres.map(genre => {
const Icon = getGenreIcon(genre.value);
const color = genreColor(genre.value);
return (
<div
key={genre.value}
className="genre-card"
style={{ '--genre-color': color } as React.CSSProperties}
onClick={() => handleGenreClick(genre.value)}
role="button"
tabIndex={0}
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)}
data-tooltip={genre.value}
>
<div className="genre-card-watermark">
<Icon size={80} strokeWidth={1.2} />
</div>
<p className="genre-card-name">{genre.value}</p>
<p className="genre-card-count">
{t('genres.albumCount', { count: genre.albumCount })}
</p>
</div>
);
})}
</div>
)}
</div>
);
}
+12 -4
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle, WifiOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FaqItem { q: string; a: string; }
@@ -64,7 +64,6 @@ export default function Help() {
items: [
{ q: t('help.q12'), a: t('help.a12') },
{ q: t('help.q13'), a: t('help.a13') },
{ q: t('help.q14'), a: t('help.a14') },
{ q: t('help.q15'), a: t('help.a15') },
{ q: t('help.q31'), a: t('help.a31') },
{ q: t('help.q32'), a: t('help.a32') },
@@ -87,6 +86,15 @@ export default function Help() {
{ q: t('help.q28'), a: t('help.a28') },
],
},
{
icon: <WifiOff size={18} />,
title: t('help.s8'),
items: [
{ q: t('help.q34'), a: t('help.a34') },
{ q: t('help.q35'), a: t('help.a35') },
{ q: t('help.q36'), a: t('help.a36') },
],
},
{
icon: <Wrench size={18} />,
title: t('help.s6'),
@@ -103,9 +111,9 @@ export default function Help() {
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.25rem', alignItems: 'start' }}>
<div style={{ columns: 2, columnGap: '1.25rem' }}>
{sections.map((section, si) => (
<section key={si} className="settings-section">
<section key={si} className="settings-section" style={{ breakInside: 'avoid', marginBottom: '1.25rem' }}>
<div className="settings-section-header">
{section.icon}
<h2>{section.title}</h2>
+41 -20
View File
@@ -1,17 +1,27 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
const PAGE_SIZE = 30;
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; });
return union.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
}
export default function NewReleases() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const PAGE_SIZE = 30;
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
const load = useCallback(async (offset: number, append = false) => {
setLoading(true);
@@ -25,34 +35,44 @@ export default function NewReleases() {
}
}, []);
useEffect(() => { setPage(0); load(0); }, [load]);
const loadFiltered = useCallback(async (genres: string[]) => {
setLoading(true);
try {
setAlbums(await fetchByGenres(genres));
setHasMore(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres);
else { setPage(0); load(0); }
}, [filtered, selectedGenres, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
if (loading || !hasMore || filtered) return;
const next = page + 1;
setPage(next);
load(next * PAGE_SIZE, true);
}, [loading, hasMore, page, load]);
}, [loading, hasMore, page, load, filtered]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore();
}
},
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore]);
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
<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} />
</div>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
@@ -62,10 +82,11 @@ export default function NewReleases() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
</div>
+18 -92
View File
@@ -156,34 +156,6 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
);
}
// ─── Blurred background ───────────────────────────────────────────────────────
const NpBg = memo(function NpBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const nextId = useRef(1);
useEffect(() => {
if (!url) return;
const id = nextId.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
return (
<div className="np-bg-wrap">
{layers.map(l => (
<div key={l.id} className="np-bg-layer"
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
/>
))}
<div className="np-bg-overlay" />
</div>
);
});
// ─── Album Tracklist ──────────────────────────────────────────────────────────
@@ -286,47 +258,12 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
const [ambilightColors, setAmbilightColors] = useState({
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
ml: '0,0,0', mr: '0,0,0',
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
});
useEffect(() => {
if (!resolvedCover) return;
const img = new Image();
img.onload = () => {
const S = 30;
const canvas = document.createElement('canvas');
canvas.width = S; canvas.height = S;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, S, S);
const data = ctx.getImageData(0, 0, S, S).data;
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
const avg = (x0: number, y0: number, x1: number, y1: number) => {
let r = 0, g = 0, b = 0, n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const i = (y * S + x) * 4;
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
}
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
};
setAmbilightColors({
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
});
};
img.src = resolvedCover;
}, [resolvedCover]);
const similarArtists = artistInfo?.similarArtist ?? [];
return (
<div className="np-page">
<NpBg url={resolvedCover ?? ''} />
<div className="np-main">
{currentTrack ? (
@@ -375,23 +312,9 @@ export default function NowPlaying() {
{/* Center: cover */}
<div className="np-hero-cover-wrap">
<div style={{
position: 'absolute', inset: '-20px', zIndex: 0,
background: `
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
`,
filter: 'blur(28px)',
}} />
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
? <img src={resolvedCover} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
}
</div>
@@ -404,7 +327,7 @@ export default function NowPlaying() {
</div>
{/* ── About the Artist ── */}
{(artistInfo?.biography || artistInfo?.largeImageUrl) && (
{artistInfo?.biography && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
@@ -416,19 +339,22 @@ export default function NowPlaying() {
</div>
<div className="np-artist-bio-row">
{artistInfo.largeImageUrl && (
<img src={artistInfo.largeImageUrl} alt={currentTrack.artist} className="np-artist-thumb" />
)}
{artistInfo.biography && (
<div className="np-bio-wrap">
<div
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
/>
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
</button>
</div>
<img
src={artistInfo.largeImageUrl}
alt={currentTrack.artist}
className="np-artist-thumb"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="np-bio-wrap">
<div
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
/>
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
</button>
</div>
</div>
</div>
)}
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from '../components/CachedImage';
export default function OfflineLibrary() {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineTracks = useOfflineStore(s => s.tracks);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
const buildTracks = (albumId: string) => {
const meta = offlineAlbums[`${serverId}:${albumId}`];
if (!meta) return [];
return meta.trackIds.flatMap(tid => {
const t = offlineTracks[`${serverId}:${tid}`];
if (!t) return [];
return [{
id: t.id, title: t.title, artist: t.artist, album: t.album,
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
coverArt: t.coverArt, track: undefined, year: t.year,
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
replayGainTrackDb: t.replayGainTrackDb,
replayGainAlbumDb: t.replayGainAlbumDb,
replayGainPeak: t.replayGainPeak,
}];
});
};
const handlePlay = (albumId: string) => {
const tracks = buildTracks(albumId);
if (tracks[0]) playTrack(tracks[0], tracks);
};
const handleEnqueue = (albumId: string) => {
enqueue(buildTracks(albumId));
};
return (
<div className="offline-library animate-fade-in">
<div className="offline-library-header">
<HardDriveDownload size={24} />
<div>
<h1 className="offline-library-title">{t('connection.offlineLibraryTitle')}</h1>
<p className="offline-library-count">
{t('connection.offlineAlbumCount', { n: albums.length, count: albums.length })}
</p>
</div>
</div>
{albums.length === 0 ? (
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
) : (
<div className="album-grid-wrap">
{albums.map(album => {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
return (
<div key={album.id} className="album-card card offline-library-card">
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
) : (
<div className="album-card-cover-placeholder">
<HardDriveDownload size={32} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={() => handlePlay(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>
<p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
<div className="offline-library-card-meta">
<button
className="offline-library-enqueue"
onClick={() => handleEnqueue(album.id)}
title="Zur Warteschlange hinzufügen"
>
+ Queue
</button>
<span className="offline-library-tracks">{trackCount} tracks</span>
<button
className="offline-library-delete"
onClick={() => deleteAlbum(album.id, serverId)}
data-tooltip={t('albumDetail.removeOffline')}
data-tooltip-pos="top"
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+614
View File
@@ -0,0 +1,614 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw } from 'lucide-react';
import {
getPlaylist, updatePlaylist, search, setRating, star, unstar,
getSimilarSongs2, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function codecLabel(song: SubsonicSong): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
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>
);
}
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [ratings, setRatings] = useState<Record<string, number>>({});
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
// ── 2×2 cover quad (first 4 unique album covers) ─────────────
const coverQuad = useMemo(() => {
const seen = new Set<string>();
const result: string[] = [];
for (const s of songs) {
if (s.coverArt && !seen.has(s.coverArt)) {
seen.add(s.coverArt);
result.push(s.coverArt);
if (result.length === 4) break;
}
}
return result;
}, [songs]);
// One resolved URL for the blurred background (must be called unconditionally)
// useMemo is required here — buildCoverArtUrl generates a new salt on every call,
// which would change bgFetchUrl every render and cause useCachedUrl to re-fetch in a loop.
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
// Song search
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
const [searching, setSearching] = useState(false);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Suggestions
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
// DnD
const tracklistRef = useRef<HTMLDivElement>(null);
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// ── Load ─────────────────────────────────────────────────────
useEffect(() => {
if (!id) return;
setLoading(true);
getPlaylist(id)
.then(({ playlist, songs }) => {
setPlaylist(playlist);
setSongs(songs);
const init: Record<string, number> = {};
const starred = new Set<string>();
songs.forEach(s => {
if (s.userRating) init[s.id] = s.userRating;
if (s.starred) starred.add(s.id);
});
setRatings(init);
setStarredSongs(starred);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [id]);
// ── Suggestions ───────────────────────────────────────────────
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
const withArtist = currentSongs.filter(s => s.artistId);
if (!withArtist.length) return;
const pick = withArtist[Math.floor(Math.random() * withArtist.length)];
const existingIds = new Set(currentSongs.map(s => s.id));
setLoadingSuggestions(true);
setSuggestions([]);
try {
const similar = await getSimilarSongs2(pick.artistId!, 25);
setSuggestions(similar.filter(s => !existingIds.has(s.id)).slice(0, 10));
} catch {}
setLoadingSuggestions(false);
}, []);
useEffect(() => {
if (songs.length > 0) loadSuggestions(songs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlist?.id]);
// ── Save ──────────────────────────────────────────────────────
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
if (!id) return;
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id));
if (id) touchPlaylist(id);
} catch {}
setSaving(false);
}, [id, touchPlaylist]);
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const next = songs.filter((_, i) => i !== idx);
setSongs(next);
savePlaylist(next);
};
// ── Add ───────────────────────────────────────────────────────
const addSong = (song: SubsonicSong) => {
if (songs.some(s => s.id === song.id)) return;
const next = [...songs, song];
setSongs(next);
savePlaylist(next);
setSuggestions(prev => prev.filter(s => s.id !== song.id));
setSearchResults(prev => prev.filter(s => s.id !== song.id));
};
// ── Rating / Star ─────────────────────────────────────────────
const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating }));
setRating(songId, rating).catch(() => {});
};
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const isStarred = starredSongs.has(song.id);
setStarredSongs(prev => {
const next = new Set(prev);
isStarred ? next.delete(song.id) : next.add(song.id);
return next;
});
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
};
// ── Search ────────────────────────────────────────────────────
useEffect(() => {
if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; }
if (searchDebounce.current) clearTimeout(searchDebounce.current);
searchDebounce.current = setTimeout(async () => {
setSearching(true);
try {
const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 });
const existingIds = new Set(songs.map(s => s.id));
setSearchResults(res.songs.filter(s => !existingIds.has(s.id)));
} catch {}
setSearching(false);
}, 350);
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchQuery, searchOpen, songs]);
// ── psy-drop DnD reordering ───────────────────────────────────
useEffect(() => {
const container = tracklistRef.current;
if (!container) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: any;
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'playlist_reorder') return;
setDropTargetIdx(null);
const fromIdx: number = parsed.index;
// Determine drop index from the event target row
const target = (e.target as HTMLElement).closest('[data-track-idx]');
let toIdx = songs.length;
if (target) {
const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10);
const rect = target.getBoundingClientRect();
const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2);
const before = cursorY < rect.top + rect.height / 2;
toIdx = before ? targetIdx : targetIdx + 1;
}
if (fromIdx === toIdx || fromIdx === toIdx - 1) return;
setSongs(prev => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx;
next.splice(insertAt, 0, moved);
savePlaylist(next);
return next;
});
};
container.addEventListener('psy-drop', onPsyDrop);
return () => container.removeEventListener('psy-drop', onPsyDrop);
}, [songs, savePlaylist]);
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, input')) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
startDrag(
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
// ── Drag-over visual feedback ─────────────────────────────────
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
if (!isDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
setDropTargetIdx({ idx, before });
};
// ── Render ────────────────────────────────────────────────────
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
if (!playlist) {
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
}
const existingIds = new Set(songs.map(s => s.id));
return (
<div className="content-body animate-fade-in">
{/* ── Hero ── */}
<div className="album-detail-header">
{resolvedBgUrl && (
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
)}
<div className="album-detail-overlay" aria-hidden="true" />
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
<ChevronLeft size={16} /> {t('playlists.title')}
</button>
<div className="album-detail-hero">
{/* 2×2 cover grid */}
<div className="playlist-cover-grid">
{Array.from({ length: 4 }, (_, i) => {
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
if (!coverId) {
return <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />;
}
return (
<CachedImage
key={i}
className="playlist-cover-cell"
src={buildCoverArtUrl(coverId, 200)}
cacheKey={coverArtCacheKey(coverId, 200)}
alt=""
/>
);
})}
</div>
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
<h1 className="album-detail-title">{playlist.name}</h1>
<div className="album-detail-info">
<span>{t('playlists.songs', { n: songs.length })}</span>
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
const tracks = songs.map(songToTrack);
playTrack(tracks[0], tracks);
}}>
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
enqueue(songs.map(songToTrack));
}}>
<ListPlus size={16} /> {t('playlists.addToQueue')}
</button>
</div>
<button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); }}
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
</div>
</div>
</div>
</div>
</div>
{/* ── Song search panel ── */}
{searchOpen && (
<div className="playlist-search-panel">
<div className="playlist-search-input-wrap">
<input
className="input"
placeholder={t('playlists.searchPlaceholder')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
autoFocus
/>
{searchQuery && (
<button className="live-search-clear" onClick={() => { setSearchQuery(''); setSearchResults([]); }}>
<X size={14} />
</button>
)}
</div>
{searching && <div style={{ textAlign: 'center', padding: '0.75rem' }}><div className="spinner" /></div>}
{!searching && searchQuery && searchResults.length === 0 && (
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
)}
{searchResults.map(song => (
<div key={song.id} className="playlist-search-row">
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
<div className="playlist-search-info">
<span className="playlist-search-title">{song.title}</span>
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
</div>
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
<button
className="playlist-search-add-btn"
data-tooltip={t('playlists.addSong')}
onClick={() => addSong(song)}
>
<Plus size={14} />
</button>
</div>
))}
</div>
)}
{/* ── Tracklist ── */}
<div className="tracklist" ref={tracklistRef}>
{/* Header */}
<div className="tracklist-header tracklist-va tracklist-playlist">
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div />
</div>
{songs.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('playlists.emptyPlaylist')}</div>
)}
{songs.map((song, idx) => (
<React.Fragment key={song.id + idx}>
{/* Drop indicator above row */}
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
<div
data-track-idx={idx}
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
onMouseLeave={() => setHoveredSongId(null)}
onMouseDown={e => handleRowMouseDown(e, idx)}
onDoubleClick={() => {
const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks);
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{/* # — play on click, grip icon on hover */}
<div
className="track-num"
style={{ cursor: 'pointer', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => { const tracks = songs.map(songToTrack); playTrack(tracks[idx], tracks); }}
>
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <GripVertical size={13} />
: currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: idx + 1}
</div>
{/* Title */}
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
{/* Artist */}
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
{/* Favorite */}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => handleToggleStar(song, e)}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
{/* Rating */}
<StarRating value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />
{/* Duration */}
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
{/* Format */}
<div className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
{/* Delete */}
<div className="playlist-row-delete-cell">
<button
className="playlist-row-delete-btn"
onClick={e => { e.stopPropagation(); removeSong(idx); }}
data-tooltip={t('playlists.removeSong')}
data-tooltip-pos="left"
>
<Trash2 size={13} />
</button>
</div>
</div>
{/* Drop indicator below last row or between rows */}
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
</React.Fragment>
))}
{/* Total row */}
{songs.length > 0 && (
<div className="tracklist-total tracklist-va tracklist-playlist">
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>
</div>
)}
</div>
{/* ── Suggestions ── */}
<div className="playlist-suggestions tracklist">
<div className="playlist-suggestions-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
<button
className="btn btn-surface"
onClick={() => loadSuggestions(songs)}
disabled={loadingSuggestions || songs.length === 0}
data-tooltip={t('playlists.refreshSuggestions')}
>
<RefreshCw size={14} className={loadingSuggestions ? 'spin-slow' : ''} />
{t('playlists.refreshSuggestions')}
</button>
</div>
{!loadingSuggestions && suggestions.filter(s => !existingIds.has(s.id)).length === 0 && (
<div className="empty-state" style={{ padding: '1.5rem 0', fontSize: '0.85rem' }}>{t('playlists.noSuggestions')}</div>
)}
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
<>
<div className="tracklist-header tracklist-va tracklist-playlist" style={{ marginTop: 'var(--space-3)' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div />
<div />
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div />
</div>
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
<div
key={song.id}
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onDoubleClick={() => addSong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="track-num" style={{ color: 'var(--text-muted)' }}>
{idx + 1}
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
{/* no star/rating for suggestions */}
<div />
<div />
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
<div className="playlist-row-delete-cell">
<button
className="playlist-row-delete-btn"
style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }}
onClick={e => { e.stopPropagation(); addSong(song); }}
data-tooltip={t('playlists.addSong')}
data-tooltip-pos="left"
>
<Plus size={13} />
</button>
</div>
</div>
))}
</>
)}
</div>
</div>
);
}
+147 -106
View File
@@ -1,137 +1,178 @@
import React, { useEffect, useState, useMemo } from 'react';
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, X } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import { useTranslation } from 'react-i18next';
type SortKey = 'name' | 'songCount' | 'duration';
type SortDir = 'asc' | 'desc';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function SortHeader({
label, sortKey, current, dir, onSort
}: {
label: string;
sortKey: SortKey;
current: SortKey;
dir: SortDir;
onSort: (k: SortKey) => void;
}) {
const active = current === sortKey;
return (
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
{label}
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
</button>
);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function Playlists() {
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack } = usePlayerStore();
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [playingId, setPlayingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const playTrack = usePlayerStore(s => s.playTrack);
const clearQueue = usePlayerStore(s => s.clearQueue);
const fetchPlaylists = () => {
setLoading(true);
useEffect(() => {
getPlaylists()
.then(data => { setPlaylists(data); setLoading(false); })
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
};
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchPlaylists(); }, []);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
}, [creating]);
const handleSort = (key: SortKey) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const handlePlay = async (id: string) => {
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
} catch (e) { console.error('Failed to play playlist', e); }
await createPlaylist(name);
const updated = await getPlaylists();
setPlaylists(updated);
} catch {}
setCreating(false);
setNewName('');
};
const handleDelete = async (id: string, name: string) => {
if (confirm(t('playlists.confirmDelete', { name }))) {
try { await deletePlaylist(id); fetchPlaylists(); }
catch (e) { console.error('Failed to delete playlist', e); }
const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => {
e.stopPropagation();
if (playingId === pl.id) return;
setPlayingId(pl.id);
try {
const data = await getPlaylist(pl.id);
const tracks = data.songs.map(songToTrack);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
}
} catch {}
setPlayingId(null);
};
const handleDelete = async (e: React.MouseEvent, pl: SubsonicPlaylist) => {
e.stopPropagation();
if (deleteConfirmId !== pl.id) {
setDeleteConfirmId(pl.id);
return;
}
try {
await deletePlaylist(pl.id);
removeId(pl.id);
setPlaylists((prev) => prev.filter((p) => p.id !== pl.id));
} catch {}
setDeleteConfirmId(null);
};
const visible = useMemo(() => {
const q = filter.toLowerCase();
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
return [...filtered].sort((a, b) => {
let cmp = 0;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
else cmp = a.duration - b.duration;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [playlists, filter, sortKey, sortDir]);
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
return (
<div className="content-body animate-fade-in">
<div className="playlist-page-header">
<h1 className="page-title">{t('playlists.title')}</h1>
<input
className="playlist-filter-input"
type="search"
placeholder={t('playlists.filterPlaceholder')}
value={filter}
onChange={e => setFilter(e.target.value)}
/>
{/* ── Header row ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
</div>
</div>
{loading ? (
<div className="loading-center"><div className="spinner" /></div>
) : playlists.length === 0 ? (
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
{/* ── Grid ── */}
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : (
<div className="playlist-list">
<div className="playlist-list-header">
<div />
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
<div />
</div>
<div className="album-grid-wrap">
{playlists.map((pl) => (
<div
key={pl.id}
className="album-card"
onClick={() => navigate(`/playlists/${pl.id}`)}
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
>
{/* Cover area — playlist SVG placeholder */}
<div className="album-card-cover">
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
</div>
{visible.length === 0 ? (
<div className="empty-state">{t('playlists.noResults')}</div>
) : visible.map(p => (
<div key={p.id} className="playlist-row">
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
<Play size={14} fill="currentColor" />
</button>
<span className="playlist-name truncate">{p.name}</span>
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
<span className="playlist-meta">{formatDuration(p.duration)}</span>
<button
className="btn btn-ghost playlist-delete-btn"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip={t('playlists.deleteTooltip')}
>
<Trash2 size={15} />
</button>
{/* Play overlay — same pattern as AlbumCard */}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={(e) => handlePlay(e, pl)}
disabled={playingId === pl.id}
>
{playingId === pl.id
? <span className="spinner" style={{ width: 14, height: 14 }} />
: <Play size={15} fill="currentColor" />
}
</button>
</div>
{/* Delete button — top-right corner */}
<button
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
data-tooltip-pos="bottom"
>
<X size={12} />
</button>
</div>
<div className="album-card-info">
<div className="album-card-title">{pl.name}</div>
<div className="album-card-artist">
{t('playlists.songs', { n: pl.songCount })}
{pl.duration > 0 && <> · {formatDuration(pl.duration)}</>}
</div>
</div>
</div>
))}
</div>
+34 -14
View File
@@ -1,23 +1,40 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
const ALBUM_COUNT = 30;
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);
}
export default function RandomAlbums() {
const { t } = useTranslation();
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 load = useCallback(async () => {
const load = useCallback(async (genres: string[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const data = await getAlbumList('random', ALBUM_COUNT);
const data = genres.length > 0
? await fetchByGenres(genres)
: await getAlbumList('random', ALBUM_COUNT);
setAlbums(data);
} catch (e) {
console.error(e);
@@ -27,21 +44,24 @@ export default function RandomAlbums() {
}
}, []);
useEffect(() => { load(); }, [load]);
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' }}>
<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>
<button
className="btn btn-ghost"
onClick={load}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className="btn btn-ghost"
onClick={() => load(selectedGenres)}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
</div>
</div>
{loading && albums.length === 0 ? (
+177 -130
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -12,23 +13,6 @@ const AUDIOBOOK_GENRES = [
'fantasy', 'comedy', 'literature',
];
interface SuperGenre {
id: string;
label: string;
keywords: string[];
}
const SUPER_GENRES: SuperGenre[] = [
{ id: 'metal', label: 'Metal', keywords: ['metal', 'thrash', 'doom', 'sludge', 'hardcore', 'grindcore', 'deathcore', 'metalcore', 'stoner', 'crust', 'black', 'death'] },
{ id: 'rock', label: 'Rock', keywords: ['rock', 'punk', 'grunge', 'alternative', 'indie', 'post-rock', 'prog', 'garage', 'psychedelic', 'shoegaze'] },
{ id: 'pop', label: 'Pop', keywords: ['pop', 'synth-pop', 'dream pop', 'electropop', 'indie pop', 'dance pop'] },
{ id: 'electronic', label: 'Electronic', keywords: ['electronic', 'techno', 'trance', 'ambient', 'edm', 'house', 'dubstep', 'drum and bass', 'dnb', 'electro', 'idm', 'synthwave', 'darkwave', 'industrial'] },
{ id: 'jazz', label: 'Jazz', keywords: ['jazz', 'blues', 'soul', 'funk', 'swing', 'bebop', 'fusion'] },
{ id: 'classical', label: 'Classical', keywords: ['classical', 'orchestra', 'symphony', 'baroque', 'opera', 'chamber', 'romantic'] },
{ id: 'hiphop', label: 'Hip-Hop', keywords: ['hip-hop', 'hip hop', 'rap', 'r&b', 'rnb', 'trap', 'grime'] },
{ id: 'country', label: 'Country', keywords: ['country', 'folk', 'bluegrass', 'americana', 'western'] },
{ id: 'world', label: 'World', keywords: ['world', 'latin', 'reggae', 'ska', 'afro', 'celtic', 'flamenco', 'bossa nova'] },
];
function formatDuration(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -45,9 +29,11 @@ export default function RandomMix() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
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 [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
// Blacklist panel state
const [blacklistOpen, setBlacklistOpen] = useState(false);
@@ -55,7 +41,9 @@ export default function RandomMix() {
// Genre Mix state
const [serverGenres, setServerGenres] = useState<SubsonicGenre[]>([]);
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
const [allAvailableGenres, setAllAvailableGenres] = useState<string[]>([]);
const [displayedGenres, setDisplayedGenres] = useState<string[]>([]);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixComplete, setGenreMixComplete] = useState(false);
@@ -80,7 +68,16 @@ export default function RandomMix() {
useEffect(() => {
fetchSongs();
getGenres().then(setServerGenres).catch(() => {});
getGenres().then(data => {
setServerGenres(data);
const audiobookLower = AUDIOBOOK_GENRES.map(g => g.toLowerCase());
const available = data
.filter(g => g.songCount > 0 && !audiobookLower.some(ab => g.value.toLowerCase().includes(ab)))
.sort((a, b) => b.songCount - a.songCount)
.map(g => g.value);
setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20));
}).catch(() => {});
}, []);
const filteredSongs = songs.filter(song => {
@@ -94,14 +91,15 @@ export default function RandomMix() {
if (song.genre && checkText(song.genre)) return false;
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
return true;
});
const handlePlayAll = () => {
if (selectedSuperGenre && genreMixSongs.length > 0) {
playTrack(genreMixSongs[0], genreMixSongs);
if (selectedGenre && genreMixSongs.length > 0) {
playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack));
} else if (filteredSongs.length > 0) {
playTrack(filteredSongs[0], filteredSongs);
playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
}
};
@@ -122,52 +120,26 @@ export default function RandomMix() {
}
};
// Compute which super-genres have matching server genres
const availableSuperGenres = SUPER_GENRES.filter(sg =>
serverGenres.some(sg2 =>
sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))
)
);
const loadGenreMix = async (superGenreId: string) => {
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
if (!sg) return;
const allMatched = serverGenres
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
.map(sg2 => sg2.value)
.sort(() => Math.random() - 0.5);
const matched = allMatched.slice(0, 50);
const loadGenreMix = async (genre: string) => {
setGenreMixLoading(true);
setGenreMixComplete(false);
setGenreMixSongs([]);
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
const accumulated: SubsonicSong[] = [];
let resolved = 0;
await Promise.allSettled(matched.map(g =>
getRandomSongs(perGenre, g, 45000).then(songs => {
accumulated.push(...songs);
resolved++;
// Show first batch immediately; update on every subsequent resolve
setGenreMixSongs([...accumulated]);
if (resolved === 1) setGenreMixLoading(false);
}).catch(() => { resolved++; })
));
// Final shuffle once all requests are done
setGenreMixSongs(prev => {
const s = [...prev];
for (let i = s.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[s[i], s[j]] = [s[j], s[i]];
}
return s.slice(0, 50);
});
try {
const fetched = await getRandomSongs(50, genre, 45000);
setGenreMixSongs(fetched);
} catch {}
setGenreMixLoading(false);
setGenreMixComplete(true);
};
const shuffleDisplayedGenres = () => {
const shuffled = [...allAvailableGenres].sort(() => Math.random() - 0.5);
setDisplayedGenres(shuffled.slice(0, 20));
setSelectedGenre(null);
setGenreMixSongs([]);
setGenreMixComplete(false);
};
return (
<div className="content-body animate-fade-in">
@@ -179,8 +151,8 @@ export default function RandomMix() {
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</button>
{(() => {
const isGenreLoading = selectedSuperGenre && !genreMixComplete;
const isDisabled = loading || (selectedSuperGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
const isGenreLoading = selectedGenre && !genreMixComplete;
const isDisabled = loading || (selectedGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
return (
<button
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
@@ -211,9 +183,12 @@ export default function RandomMix() {
}}>
{/* Left: Blacklist */}
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('randomMix.filterPanelDesc')}
</p>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
<input
@@ -296,34 +271,47 @@ export default function RandomMix() {
{t('randomMix.genreMixTitle')}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}>
{serverGenres.length === 0 ? (
<div className="spinner" style={{ width: 14, height: 14 }} />
) : availableSuperGenres.length === 0 ? (
) : displayedGenres.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
) : (
availableSuperGenres.map(sg => (
<button
key={sg.id}
className={`btn ${selectedSuperGenre === sg.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => { setSelectedSuperGenre(sg.id); loadGenreMix(sg.id); }}
disabled={genreMixLoading}
>
{sg.label}
</button>
))
<>
{displayedGenres.map(genre => (
<button
key={genre}
className={`btn ${selectedGenre === genre ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => { setSelectedGenre(genre); loadGenreMix(genre); }}
disabled={genreMixLoading}
>
{genre}
</button>
))}
{allAvailableGenres.length > 20 && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={shuffleDisplayedGenres}
disabled={genreMixLoading}
data-tooltip={t('randomMix.shuffleGenres')}
>
<RefreshCw size={12} />
</button>
)}
</>
)}
</div>
</div>
</div>
{/* Genre Mix tracklist (shown when a super-genre is selected) */}
{/* Genre Mix tracklist (shown when a genre is selected) */}
{(genreMixLoading || genreMixSongs.length > 0) && (
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
{selectedGenre} Mix
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
</span>
</div>
@@ -339,31 +327,45 @@ export default function RandomMix() {
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{genreMixSongs.map(song => (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }, 'song'); }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
{genreMixSongs.map(song => {
const track = songToTrack(song);
return (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
</div>
))}
</div>
)}
</div>
)}
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
</div>
);
})}
</div>
)}
</div>
)}
{!selectedSuperGenre && (loading && songs.length === 0 ? (
{!selectedGenre && (loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
@@ -381,34 +383,40 @@ export default function RandomMix() {
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{filteredSongs.map((song) => (
<div
key={song.id}
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(song, filteredSongs)}
role="row"
draggable
onContextMenu={e => {
e.preventDefault();
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred };
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
{filteredSongs.map((song) => {
const track = songToTrack(song);
return (
<div
key={song.id}
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
role="row"
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, filteredSongs); }}
onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
data-tooltip={t('randomMix.play')}
>
<Play size={14} fill="currentColor" />
@@ -419,7 +427,45 @@ export default function RandomMix() {
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
{(() => {
const artist = song.artist;
if (!artist) return <span className="track-artist"></span>;
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedArtist === artist;
return (
<button
className="btn btn-ghost track-artist"
style={{
fontSize: 'inherit',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
textAlign: 'left',
}}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
setAddedArtist(artist);
setTimeout(() => setAddedArtist(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>
{artist}
</button>
);
})()}
</div>
<div className="track-info">
@@ -478,12 +524,13 @@ export default function RandomMix() {
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
))}
</div>
))}
{formatDuration(song.duration)}
</span>
</div>
);
})}
</div>
))}
</div>
);
+18 -21
View File
@@ -2,10 +2,11 @@ import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Play, Search } from 'lucide-react';
import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -19,6 +20,7 @@ export default function SearchResults() {
const [loading, setLoading] = useState(false);
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop();
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
@@ -31,17 +33,7 @@ export default function SearchResults() {
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const playSong = (song: SubsonicSong, list: SubsonicSong[]) => {
playTrack({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
}, list.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, year: s.year, bitRate: s.bitRate,
suffix: s.suffix, userRating: s.userRating,
})));
playTrack(songToTrack(song), list.map(songToTrack));
};
return (
@@ -92,16 +84,21 @@ export default function SearchResults() {
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const track = songToTrack(song);
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
+172 -27
View File
@@ -4,9 +4,12 @@ import changelogRaw from '../../CHANGELOG.md?raw';
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
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
@@ -23,6 +26,32 @@ import Equalizer from '../components/Equalizer';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
const CONTRIBUTORS = [
{
github: 'jiezhuo',
since: '1.21',
contributions: [
'Chinese (Simplified) translation',
],
},
{
github: 'nullobject',
since: '1.22.0',
contributions: [
'Seek debounce & race condition fix (PR #7)',
'Waveform seekbar stability on position update (PR #8)',
],
},
{
github: 'trbn1',
since: '1.22.0',
contributions: [
'Replay Gain metadata propagation (PR #9)',
'songToTrack() — unified track construction across all sources',
],
},
] as const;
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
@@ -83,12 +112,20 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
const fontStore = useFontStore();
const kb = useKeybindingsStore();
const gs = useGlobalShortcutsStore();
const serverId = auth.activeServerId ?? '';
const clearAllOffline = useOfflineStore(s => s.clearAll);
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
const navigate = useNavigate();
@@ -103,12 +140,37 @@ export default function Settings() {
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
const [lfmError, setLfmError] = useState<string | null>(null);
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false);
useEffect(() => {
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {});
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
useEffect(() => {
if (activeTab !== 'library') return;
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
}, [activeTab]);
const handleClearCache = useCallback(async () => {
setClearing(true);
await clearImageCache();
await clearAllOffline(serverId);
const [imgBytes, offBytes] = await Promise.all([
getImageCacheSize(),
invoke<number>('get_offline_cache_size').catch(() => 0),
]);
setImageCacheBytes(imgBytes);
setOfflineCacheBytes(offBytes);
setShowClearConfirm(false);
setClearing(false);
}, [clearAllOffline, serverId]);
const startLastfmConnect = useCallback(async () => {
setLfmError(null);
let token: string;
@@ -296,19 +358,22 @@ export default function Settings() {
<div className="divider" />
{/* Crossfade */}
<div className="settings-toggle-row">
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.crossfade')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
<input type="checkbox" checked={auth.crossfadeEnabled} onChange={e => auth.setCrossfadeEnabled(e.target.checked)} id="crossfade-toggle" />
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
<span className="toggle-track" />
</label>
</div>
{auth.crossfadeEnabled && (
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
@@ -329,15 +394,18 @@ export default function Settings() {
<div className="divider" />
{/* Gapless */}
<div className="settings-toggle-row">
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.gapless')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
<input type="checkbox" checked={auth.gaplessEnabled} onChange={e => auth.setGaplessEnabled(e.target.checked)} id="gapless-toggle" />
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
<span className="toggle-track" />
</label>
</div>
@@ -357,15 +425,24 @@ export default function Settings() {
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
</div>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.cacheDesc')}
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
<span style={{ marginLeft: 6, color: 'var(--text-secondary)' }}>
{t('settings.cacheUsed', {
images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…',
offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…',
})}
</span>
)}
</div>
<div className="settings-toggle-row" style={{ marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{auth.maxCacheMb} MB</span>
<input
type="range"
min={100}
max={2000}
max={5000}
step={100}
value={auth.maxCacheMb}
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
@@ -373,6 +450,28 @@ export default function Settings() {
id="cache-size-slider"
/>
</div>
{showClearConfirm ? (
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
onClick={handleClearCache}
disabled={clearing}
>
{t('settings.cacheClearConfirm')}
</button>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
{t('settings.cacheClearCancel')}
</button>
</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
</div>
</section>
@@ -480,6 +579,7 @@ export default function Settings() {
{ value: 'en', label: t('settings.languageEn') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'zh', label: t('settings.languageZh') },
]}
/>
</div>
@@ -872,19 +972,6 @@ export default function Settings() {
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.trayTitle')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
<span className="toggle-track" />
</label>
</div>
<div className="divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
@@ -957,6 +1044,52 @@ export default function Settings() {
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
<div>
<button
style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textAlign: 'left' }}
onClick={() => setContributorsOpen(o => !o)}
>
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0 }}>{t('settings.aboutContributorsLabel')}</span>
<span style={{ color: 'var(--text-secondary)', flex: 1 }}>{CONTRIBUTORS.length}</span>
<ChevronDown size={13} style={{ color: 'var(--text-muted)', transform: contributorsOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</button>
{contributorsOpen && (
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{CONTRIBUTORS.map(c => (
<div key={c.github} style={{
display: 'flex', gap: '0.75rem', alignItems: 'flex-start',
background: 'var(--bg-elevated)', borderRadius: 'var(--radius-md)',
padding: '0.65rem 0.75rem',
boxShadow: 'inset 0 0 0 1px var(--border-subtle)',
}}>
<img
src={`https://github.com/${c.github}.png?size=48`}
width={36} height={36}
style={{ borderRadius: '50%', flexShrink: 0, marginTop: 2 }}
alt={c.github}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.3rem' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
onClick={() => openUrl(`https://github.com/${c.github}`)}
>
@{c.github}
</button>
<span style={{ fontSize: 10, background: 'var(--accent-dim)', color: 'var(--accent)', padding: '1px 6px', borderRadius: 99, fontWeight: 600 }}>
v{c.since}
</span>
</div>
<ul style={{ margin: 0, padding: '0 0 0 1rem', fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{c.contributions.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
</div>
))}
</div>
)}
</div>
</div>
<button
@@ -1001,6 +1134,8 @@ function renderInline(text: string): React.ReactNode[] {
function ChangelogSection() {
const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const versions = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## ['));
@@ -1024,6 +1159,16 @@ function ChangelogSection() {
<Info size={18} />
<h2>{t('settings.changelog')}</h2>
</div>
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
<input type="checkbox" checked={showChangelogOnUpdate} onChange={e => setShowChangelogOnUpdate(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="changelog-list">
{versions.map(({ version, date, body }) => (
<details key={version} className="changelog-entry" open={version === appVersion}>
+18 -6
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore';
export interface ServerProfile {
id: string;
@@ -21,7 +23,6 @@ interface AuthState {
lastfmUsername: string;
// Settings (global)
minimizeToTray: boolean;
scrobblingEnabled: boolean;
maxCacheMb: number;
downloadFolder: string;
@@ -32,6 +33,8 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
// Status
isLoggedIn: boolean;
@@ -51,7 +54,6 @@ interface AuthState {
connectLastfm: (sessionKey: string, username: string) => void;
disconnectLastfm: () => void;
setLastfmSessionError: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
@@ -62,6 +64,8 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
logout: () => void;
// Derived
@@ -82,7 +86,6 @@ export const useAuthStore = create<AuthState>()(
lastfmApiSecret: '',
lastfmSessionKey: '',
lastfmUsername: '',
minimizeToTray: false,
scrobblingEnabled: true,
maxCacheMb: 500,
downloadFolder: '',
@@ -93,6 +96,8 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -139,17 +144,24 @@ export const useAuthStore = create<AuthState>()(
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }),
setReplayGainMode: (v) => set({ replayGainMode: v }),
setReplayGainEnabled: (v) => {
set({ replayGainEnabled: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainMode: (v) => {
set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
logout: () => set({ isLoggedIn: false }),
+245
View File
@@ -0,0 +1,245 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import type { SubsonicSong } from '../api/subsonic';
export interface OfflineTrackMeta {
id: string;
serverId: string;
localPath: string;
title: string;
artist: string;
album: string;
albumId: string;
artistId?: string;
suffix: string;
duration: number;
bitRate?: number;
coverArt?: string;
year?: number;
genre?: string;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
cachedAt: string;
}
export interface OfflineAlbumMeta {
id: string;
serverId: string;
name: string;
artist: string;
coverArt?: string;
year?: number;
trackIds: string[];
}
export interface DownloadJob {
trackId: string;
albumId: string;
albumName: string;
trackTitle: string;
trackIndex: number;
totalTracks: number;
status: 'queued' | 'downloading' | 'done' | 'error';
}
interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
jobs: DownloadJob[];
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
isAlbumDownloading: (albumId: string) => boolean;
getLocalUrl: (trackId: string, serverId: string) => string | null;
downloadAlbum: (
albumId: string,
albumName: string,
albumArtist: string,
coverArt: string | undefined,
year: number | undefined,
songs: SubsonicSong[],
serverId: string,
) => Promise<void>;
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
clearAll: (serverId: string) => Promise<void>;
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
}
export const useOfflineStore = create<OfflineState>()(
persist(
(set, get) => ({
tracks: {},
albums: {},
jobs: [],
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
isAlbumDownloaded: (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album || album.trackIds.length === 0) return false;
return album.trackIds.every(tid => !!get().tracks[`${serverId}:${tid}`]);
},
isAlbumDownloading: (albumId) =>
get().jobs.some(
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')
),
getLocalUrl: (trackId, serverId) => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return null;
return `psysonic-local://${meta.localPath}`;
},
clearAll: async (serverId) => {
const albumKeys = Object.keys(get().albums).filter(k => k.startsWith(`${serverId}:`));
for (const key of albumKeys) {
const albumId = key.slice(`${serverId}:`.length);
await get().deleteAlbum(albumId, serverId);
}
},
getAlbumProgress: (albumId) => {
const albumJobs = get().jobs.filter(j => j.albumId === albumId);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
const CONCURRENCY = 2;
const trackIds = songs.map(s => s.id);
// Register album shell + queue jobs
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
},
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...songs.map((s, i) => ({
trackId: s.id,
albumId,
albumName,
trackTitle: s.title,
trackIndex: i,
totalTracks: songs.length,
status: 'queued' as const,
})),
],
}));
// Download in batches of CONCURRENCY
for (let i = 0; i < songs.length; i += CONCURRENCY) {
const batch = songs.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async song => {
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'downloading' }
: j,
),
}));
const suffix = song.suffix || 'mp3';
const url = buildStreamUrl(song.id);
try {
const localPath = await invoke<string>('download_track_offline', {
trackId: song.id,
serverId,
url,
suffix,
});
set(state => ({
tracks: {
...state.tracks,
[`${serverId}:${song.id}`]: {
id: song.id,
serverId,
localPath,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
suffix,
duration: song.duration,
bitRate: song.bitRate,
coverArt: song.coverArt,
year: song.year,
genre: song.genre,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
cachedAt: new Date().toISOString(),
},
},
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'done' }
: j,
),
}));
} catch {
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'error' }
: j,
),
}));
}
}),
);
}
// Clear completed jobs after a short delay
setTimeout(() => {
set(state => ({
jobs: state.jobs.filter(
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
),
}));
}, 2500);
},
deleteAlbum: async (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album) return;
await Promise.all(
album.trackIds.map(async trackId => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return;
await invoke('delete_offline_track', {
trackId,
serverId,
suffix: meta.suffix,
}).catch(() => {});
}),
);
set(state => {
const tracks = { ...state.tracks };
album.trackIds.forEach(tid => delete tracks[`${serverId}:${tid}`]);
const albums = { ...state.albums };
delete albums[`${serverId}:${albumId}`];
return { tracks, albums };
});
},
}),
{
name: 'psysonic-offline',
storage: createJSONStorage(() => localStorage),
partialize: state => ({ tracks: state.tracks, albums: state.albums }),
},
),
);
+203 -62
View File
@@ -2,9 +2,10 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
export interface Track {
id: string;
@@ -24,6 +25,7 @@ export interface Track {
replayGainAlbumDb?: number;
replayGainPeak?: number;
starred?: string;
genre?: string;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -45,6 +47,7 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
genre: song.genre,
};
}
@@ -72,8 +75,10 @@ interface PlayerState {
previous: () => void;
seek: (progress: number) => void;
setVolume: (v: number) => void;
setProgress: (t: number, duration: number) => void;
updateReplayGainForCurrentTrack: () => void;
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void;
clearQueue: () => void;
isQueueVisible: boolean;
@@ -122,6 +127,9 @@ let playGeneration = 0;
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Target time of the last seek — blocks stale Rust progress ticks until the
// engine has actually caught up to the new position.
let seekTarget: number | null = null;
// Guard against rapid double-click play/pause sending two state transitions
// to the Rust backend before it has finished the previous one.
@@ -131,6 +139,11 @@ let togglePlayLock = false;
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
// Track ID that has already been sent to audio_chain_preload / audio_preload.
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
// the last 30 seconds of a track, each spawning its own HTTP download.
let gaplessPreloadingId: string | null = null;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -151,6 +164,18 @@ function handleAudioPlaying(_duration: number) {
}
function handleAudioProgress(current_time: number, duration: number) {
// While a seek is pending, the store already holds the optimistic target
// position. Accepting stale progress from the Rust engine would briefly
// snap the waveform back to the old position before the seek completes.
if (seekDebounce) return;
// After the debounce fires, Rust may still emit 12 ticks with the old
// position before the seek takes effect. Block until current_time is
// within 2 s of the requested target, then clear the guard.
if (seekTarget !== null) {
if (Math.abs(current_time - seekTarget) > 2.0) return;
seekTarget = null;
}
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
@@ -177,8 +202,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const nextTrack = repeatMode === 'one'
? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) {
const nextUrl = buildStreamUrl(nextTrack.id);
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id);
if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in
// advance. By the time the track boundary arrives, the next source is
@@ -233,6 +260,7 @@ function handleAudioEnded() {
*/
function handleAudioTrackSwitched(duration: number) {
lastGaplessSwitchTime = Date.now();
gaplessPreloadingId = null; // allow preloading for the track after this one
isAudioPaused = false;
const store = usePlayerStore.getState();
@@ -326,8 +354,57 @@ export function initAudioListeners(): () => void {
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
});
// ── MPRIS / OS media controls sync ───────────────────────────────────────
// Whenever the current track or playback state changes, push updates to the
// Rust souvlaki MediaControls so the OS media overlay stays accurate.
let prevTrackId: string | null = null;
let prevIsPlaying: boolean | null = null;
let lastMprisPositionUpdate = 0;
const unsubMpris = usePlayerStore.subscribe((state) => {
const { currentTrack, isPlaying, currentTime } = state;
// Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) {
prevTrackId = currentTrack.id;
const coverUrl = currentTrack.coverArt
? buildCoverArtUrl(currentTrack.coverArt, 512)
: undefined;
invoke('mpris_set_metadata', {
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
coverUrl,
durationSecs: currentTrack.duration,
}).catch(() => {});
}
// Update playback state on play/pause change
const playbackChanged = isPlaying !== prevIsPlaying;
if (playbackChanged) {
prevIsPlaying = isPlaying;
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: isPlaying,
positionSecs: currentTime > 0 ? currentTime : null,
}).catch(() => {});
return;
}
// Keep position in sync while playing — update every ~500 ms so Plasma
// always shows the correct time without interpolation gaps.
if (isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
positionSecs: currentTime,
}).catch(() => {});
}
});
return () => {
unsubAuth();
unsubMpris();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -424,7 +501,7 @@ export const usePlayerStore = create<PlayerState>()(
stop: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
},
@@ -438,7 +515,8 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
const state = get();
const newQueue = queue ?? state.queue;
@@ -457,8 +535,8 @@ export const usePlayerStore = create<PlayerState>()(
isPlaying: true, // optimistic — reverted on error
});
const url = buildStreamUrl(track.id);
const authState = useAuthStore.getState();
const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id);
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null;
@@ -512,31 +590,62 @@ export const usePlayerStore = create<PlayerState>()(
isAudioPaused = false;
set({ isPlaying: true });
} else {
// Cold start (app relaunch) — audio is not loaded in Rust; re-download.
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
const gen = ++playGeneration;
const vol = get().volume;
set({ isPlaying: true });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
invoke('audio_play', {
url: buildStreamUrl(currentTrack.id),
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, currentTrack, currentTime);
// Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => {
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
// Update store with fresh track data if available
if (freshSong) set({ currentTrack: trackToPlay });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? trackToPlay.replayGainAlbumDb : trackToPlay.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, trackToPlay, currentTime);
}).catch(() => {
if (playGeneration !== gen) return;
// Fallback to currentTrack if fetch fails
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, currentTrack, currentTime);
});
}
},
@@ -587,6 +696,7 @@ export const usePlayerStore = create<PlayerState>()(
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
seekTarget = time;
invoke('audio_seek', { seconds: time }).catch(console.error);
}, 100);
},
@@ -611,10 +721,26 @@ export const usePlayerStore = create<PlayerState>()(
});
},
enqueueAt: (tracks, insertIndex) => {
set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [
...state.queue.slice(0, idx),
...tracks,
...state.queue.slice(idx),
];
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue, queueIndex: newQueueIndex };
});
},
clearQueue: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer([], null, 0);
},
@@ -657,40 +783,55 @@ export const usePlayerStore = create<PlayerState>()(
// ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map(songToTrack);
let currentTrack = mappedTracks[0];
let queueIndex = 0;
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
// Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
// Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
updateReplayGainForCurrentTrack: () => {
const { currentTrack, volume } = get();
if (!currentTrack || !currentTrack.id) return;
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album'
? currentTrack.replayGainAlbumDb
: currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled
? (currentTrack.replayGainPeak ?? null)
: null;
invoke('audio_update_replay_gain', {
volume,
replayGainDb,
replayGainPeak
}).catch(console.error);
},
}),
{
name: 'psysonic-player',
@@ -703,7 +844,7 @@ export const usePlayerStore = create<PlayerState>()(
queueIndex: state.queueIndex,
currentTime: state.currentTime,
lastfmLovedCache: state.lastfmLovedCache,
} as Partial<PlayerState>),
}),
}
)
);
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface PlaylistStore {
recentIds: string[];
touchPlaylist: (id: string) => void;
removeId: (id: string) => void;
}
export const usePlaylistStore = create<PlaylistStore>()(
persist(
(set) => ({
recentIds: [],
touchPlaylist: (id) =>
set((s) => ({
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
})),
removeId: (id) =>
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
}),
{ name: 'psysonic_playlists_recent' }
)
);
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade' | 'imperial-sith' | 'order-of-the-phoenix' | 'heisenberg' | 'ice-and-fire' | 'doh-matic';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave';
interface ThemeState {
theme: Theme;
+682 -45
View File
@@ -154,6 +154,20 @@
box-shadow: var(--shadow-glow);
}
/* ─ Page header row (title + actions) ─ */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-6);
}
.page-header .page-title {
margin-bottom: 0;
}
/* ─ Section titles ─ */
.section-title,
.page-title {
@@ -169,15 +183,14 @@
cursor: pointer;
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
overflow: hidden;
transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
box-shadow: inset 0 0 0 1px var(--border-subtle);
transition: transform var(--transition-base), box-shadow var(--transition-base);
}
.album-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
border-color: var(--border);
box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md);
}
.album-card-cover {
@@ -363,6 +376,11 @@
gap: var(--space-4);
}
.album-grid-wrap .album-card {
content-visibility: auto;
contain-intrinsic-size: 0 220px;
}
@media (min-width: 1024px) {
.album-grid-wrap {
@@ -399,6 +417,21 @@
box-shadow: var(--shadow-sm);
}
.album-card-offline-badge {
position: absolute;
top: 6px;
right: 6px;
background: color-mix(in srgb, var(--accent) 85%, transparent);
color: var(--ctp-crust);
border-radius: var(--radius-sm);
padding: 3px 4px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
.album-card-play-overlay {
position: absolute;
inset: 0;
@@ -483,13 +516,13 @@
.live-search-field {
padding-left: 36px !important;
padding-right: 32px !important;
padding-right: 58px !important;
border-radius: var(--radius-full) !important;
}
.live-search-clear {
position: absolute;
right: 12px;
right: 8px;
font-size: 18px;
color: var(--text-muted);
line-height: 1;
@@ -500,6 +533,29 @@
color: var(--text-primary);
}
.live-search-adv-btn {
position: absolute;
right: 30px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
opacity: 0.55;
transition: opacity var(--transition-fast), color var(--transition-fast);
border-radius: var(--radius-sm);
padding: 0;
}
.live-search-adv-btn:hover {
opacity: 1;
color: var(--accent);
}
.live-search-dropdown {
position: absolute;
top: calc(100% + 8px);
@@ -591,6 +647,82 @@
}
/* ─ Album Detail ─ */
/* ─── Offline Library ─── */
.offline-library {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.offline-library-header {
display: flex;
align-items: center;
gap: 14px;
color: var(--accent);
}
.offline-library-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.offline-library-count {
font-size: 13px;
color: var(--text-secondary);
margin: 2px 0 0;
}
.offline-library-card .album-card-info {
gap: 3px;
}
.offline-library-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 2px;
}
.offline-library-enqueue {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 10px;
padding: 2px 6px;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.offline-library-enqueue:hover {
color: var(--accent);
border-color: var(--accent);
}
.offline-library-tracks {
font-size: 10px;
color: var(--text-muted);
}
.offline-library-delete {
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.offline-library-delete:hover {
color: var(--color-error, #e05050);
}
.album-detail {
display: flex;
flex-direction: column;
@@ -712,6 +844,30 @@
color: var(--text-muted);
}
/* ── Playlist 2×2 cover grid ── */
.playlist-cover-grid {
flex-shrink: 0;
width: clamp(120px, 15vw, 200px);
height: clamp(120px, 15vw, 200px);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.playlist-cover-cell {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playlist-cover-cell--empty {
background: var(--bg-hover);
}
.album-detail-meta {
min-width: 0;
flex: 1;
@@ -853,6 +1009,28 @@
font-variant-numeric: tabular-nums;
}
/* ─ Offline cache button ─ */
.offline-cache-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.offline-cache-btn--cached {
color: var(--color-star-active, var(--accent));
border-color: var(--color-star-active, var(--accent));
}
.offline-cache-btn--progress {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
opacity: 0.75;
padding: 6px 10px;
}
/* ─ Download folder modal ─ */
.download-folder-pick-row {
display: flex;
@@ -923,7 +1101,7 @@
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.track-row {
@@ -939,7 +1117,7 @@
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total {
@@ -952,7 +1130,7 @@
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total-label {
@@ -982,6 +1160,60 @@
grid-column: 6 / 7;
}
/* ── Playlist tracklist variant — adds delete column ── */
.tracklist-header.tracklist-playlist,
.track-row.tracklist-playlist,
.tracklist-total.tracklist-playlist {
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
}
.tracklist-total.tracklist-playlist .tracklist-total-label {
grid-column: 1 / 6;
}
.tracklist-total.tracklist-playlist .tracklist-total-value {
grid-column: 6 / 7;
}
/* Delete button in playlist row */
.playlist-row-delete-cell {
display: flex;
align-items: center;
justify-content: center;
}
.playlist-row-delete-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
}
.track-row:hover .playlist-row-delete-btn,
.track-row.context-active .playlist-row-delete-btn {
opacity: 1;
}
.playlist-row-delete-btn:hover {
color: var(--danger);
}
/* In suggestion rows the + btn is always visible at muted opacity */
.playlist-suggestions .playlist-row-delete-btn {
opacity: 0.45;
}
.playlist-suggestions .track-row:hover .playlist-row-delete-btn {
opacity: 1;
color: var(--accent);
}
.track-row:hover,
.track-row.context-active {
background: var(--bg-hover);
@@ -1629,14 +1861,12 @@
padding: 5px 8px;
border-radius: 6px;
line-height: 1.6;
transition: color 0.25s, font-size 0.2s, font-weight 0.2s;
transition: color 0.25s;
cursor: default;
}
.lyrics-line.active {
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
color: var(--accent);
}
.lyrics-plain {
@@ -2558,6 +2788,29 @@
opacity: 1;
}
.fav-remove-btn {
opacity: 0;
color: var(--text-muted);
background: none;
border: none;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: opacity 0.15s, color 0.15s;
}
.fav-remove-btn:hover {
color: var(--ctp-red);
}
.track-row:hover .fav-remove-btn,
.track-row.context-active .fav-remove-btn {
opacity: 1;
}
/* ─ Statistics Page ─ */
.stats-page {
display: flex;
@@ -2801,6 +3054,10 @@
overflow: hidden;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--accent) 10%, var(--bg-main)) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--ctp-blue) 8%, var(--bg-main)) 0%, transparent 60%),
var(--bg-main);
}
/* Main scrollable content */
@@ -3175,38 +3432,6 @@
flex-shrink: 0;
}
.np-bg-wrap {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
contain: paint;
}
.np-bg-layer {
position: absolute;
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(70px) brightness(0.55) saturate(1.6);
transition: opacity 0.6s ease;
animation: np-ken-burns 40s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes np-ken-burns {
0% { transform: scale(1.00) translate(0%, 0%); }
25% { transform: scale(1.04) translate(-2%, -1.5%); }
50% { transform: scale(1.06) translate(1.5%, 2%); }
75% { transform: scale(1.03) translate(-1%, 1.5%); }
100% { transform: scale(1.05) translate(2%, -1%); }
}
.np-bg-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.38);
}
.np-hero-content {
position: relative;
@@ -3949,3 +4174,415 @@
padding: 1px 4px;
border-radius: 3px;
}
/* ─ Genre Filter Bar ─ */
.genre-filter-tagbox {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-card);
min-width: 220px;
cursor: text;
transition: border-color 0.15s;
}
.genre-filter-tagbox:focus-within {
border-color: var(--accent);
}
.genre-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.3rem 0.15rem 0.5rem;
background: var(--accent);
color: var(--ctp-crust);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.genre-filter-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
opacity: 0.75;
line-height: 1;
}
.genre-filter-chip button:hover { opacity: 1; }
.genre-filter-input {
border: none;
background: none;
outline: none;
color: var(--text-primary);
font-size: 0.85rem;
min-width: 100px;
flex: 1;
padding: 0.1rem 0;
}
.genre-filter-input::placeholder { color: var(--text-muted); }
.genre-filter-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
max-height: 220px;
overflow-y: auto;
overscroll-behavior: contain;
z-index: 500;
}
.genre-filter-option {
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
color: var(--text-primary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.genre-filter-option:hover {
background: var(--bg-hover);
color: var(--accent);
}
.genre-filter-empty {
padding: 0.6rem 0.75rem;
font-size: 0.82rem;
color: var(--text-muted);
}
/* ─ Genre Cards ─ */
.genre-card {
aspect-ratio: 1;
border-radius: 12px;
padding: 0.9rem;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
background:
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%),
var(--genre-color, var(--accent));
transition: transform 0.15s ease, box-shadow 0.15s ease;
user-select: none;
}
.genre-card:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
}
.genre-card-watermark {
position: absolute;
top: 50%;
right: -10px;
transform: translateY(-55%);
opacity: 0.22;
color: #fff;
pointer-events: none;
}
.genre-card-name {
font-size: 0.86rem;
font-weight: 700;
color: #fff;
margin: 0;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.genre-card-count {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.85);
margin: 0.2rem 0 0;
}
/* ─ Playlists overview header ─ */
.playlists-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-6);
}
/* ─ Playlist Card (Grid Overview) ─ */
.playlist-card-icon {
color: var(--accent);
opacity: 0.6;
}
/* Delete button — top-right corner of card cover, hidden until hover */
.playlist-card-delete {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
z-index: 2;
}
.album-card:hover .playlist-card-delete {
opacity: 1;
}
.playlist-card-delete:hover {
background: rgba(220, 60, 60, 0.85);
color: #fff;
}
.playlist-card-delete--confirm {
background: rgba(220, 60, 60, 0.9) !important;
color: #fff !important;
opacity: 1 !important;
}
/* Drop indicator line */
.playlist-drop-indicator {
height: 2px;
background: var(--accent);
border-radius: 1px;
margin: 1px 0;
pointer-events: all;
}
/* ─ Playlist Search Panel ─ */
.playlist-search-panel {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-3) var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
animation: fade-in-down 0.15s ease;
}
@keyframes fade-in-down {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.playlist-search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.playlist-search-input-wrap .input {
width: 100%;
padding-right: 36px;
}
.playlist-search-input-wrap .live-search-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
}
.playlist-search-row {
display: grid;
grid-template-columns: 36px 1fr auto 52px 28px;
align-items: center;
gap: var(--space-2);
padding: 3px var(--space-1);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.playlist-search-row:hover {
background: var(--bg-hover);
}
.playlist-search-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: var(--radius-xs);
background: var(--bg-hover);
flex-shrink: 0;
}
.playlist-search-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.playlist-search-title {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
.playlist-search-artist {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-search-album {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-search-duration {
font-size: 12px;
color: var(--text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.playlist-search-add-btn {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.playlist-search-add-btn:hover {
background: var(--accent);
color: var(--ctp-base);
}
.playlist-suggestions {
margin-top: var(--space-6);
}
.playlist-suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-3);
margin-bottom: var(--space-1);
}
/* ─ Context Menu Submenu ─ */
.context-menu-item--submenu {
position: relative;
}
.context-menu-item--submenu.active,
.context-menu-item--submenu:hover {
background: var(--surface-2);
color: var(--accent);
}
.context-submenu {
position: absolute;
top: -4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-1) 0;
min-width: 190px;
max-width: 250px;
max-height: 280px;
overflow-y: auto;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
z-index: 10001;
}
.context-submenu-empty {
padding: var(--space-2) var(--space-4);
color: var(--text-muted);
font-size: 12px;
}
.context-submenu-new {
color: var(--accent);
font-weight: 500;
}
.context-submenu-create {
display: flex;
align-items: center;
gap: 4px;
padding: var(--space-1) var(--space-2);
}
.context-submenu-input {
flex: 1;
min-width: 0;
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
padding: 3px 7px;
outline: none;
}
.context-submenu-input::placeholder {
color: var(--text-muted);
}
.context-submenu-create-btn {
flex-shrink: 0;
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: var(--ctp-crust);
padding: 4px 6px;
cursor: pointer;
display: flex;
align-items: center;
transition: opacity var(--transition-fast);
}
.context-submenu-create-btn:hover {
opacity: 0.85;
}
+166 -21
View File
@@ -9,6 +9,11 @@
"sidebar main queue"
"player player player";
height: 100vh;
/* overflow: hidden keeps the player bar pinned at the bottom even when the
window is dragged below the OS minHeight constraint (ignored on some
Linux WMs/compositors). Without this, the 1fr row's implicit auto
min-height can push the grid taller than 100vh, scrolling the player
bar out of view. */
overflow: hidden;
background: var(--bg-app);
}
@@ -39,6 +44,8 @@
background: var(--bg-sidebar);
border-right: 1px solid var(--border-subtle);
position: relative;
z-index: 2;
min-height: 0; /* allow 1fr row to shrink freely */
}
.sidebar-brand {
@@ -186,12 +193,12 @@
.collapse-btn {
position: absolute;
top: 50%;
right: -13px;
top: calc(50% - var(--player-height) / 2);
left: calc(var(--sidebar-width) - 11px);
transform: translateY(-50%);
z-index: 101;
width: 26px;
height: 26px;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--border-subtle);
background: var(--bg-card);
@@ -200,21 +207,36 @@
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
box-shadow: var(--shadow-sm);
}
.sidebar:hover .collapse-btn {
opacity: 1;
}
.collapse-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
box-shadow: var(--shadow-md);
}
.queue-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.queue-toggle-btn:hover {
background: var(--accent-dim);
color: var(--accent);
}
/* ─── Update Toast ─── */
@keyframes update-toast-in {
from { opacity: 0; transform: translateY(6px); }
@@ -279,6 +301,35 @@
cursor: default;
}
/* ─── Sidebar offline download queue ─── */
.sidebar-offline-queue {
margin: 4px var(--space-1) 0;
padding: 6px 10px;
border-radius: var(--radius-md);
background: var(--accent-dim);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
color: var(--accent);
overflow: hidden;
}
.sidebar-offline-queue--collapsed {
justify-content: center;
padding: 6px;
}
@keyframes spin-slow {
to { transform: rotate(360deg); }
}
.spin-slow {
animation: spin-slow 2s linear infinite;
flex-shrink: 0;
}
/* ─── Main Content ─── */
.main-content {
grid-area: main;
@@ -286,6 +337,7 @@
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0; /* allow 1fr row to shrink freely */
background: var(--bg-app);
z-index: 1;
}
@@ -317,6 +369,82 @@
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-6);
contain: paint;
}
/* ─── Offline Banner ─── */
.offline-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-sidebar));
border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--accent);
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.offline-banner span {
flex: 1;
}
.offline-banner-retry {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: var(--radius-sm);
color: var(--accent);
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
transition: background var(--transition-fast);
}
.offline-banner-retry:hover {
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.offline-banner-retry:disabled {
opacity: 0.5;
cursor: default;
}
/* ─── Offline Storage Full Banner ─── */
.offline-storage-full-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 10px 16px;
margin: 0 0 1rem 0;
background: color-mix(in srgb, var(--color-danger, #e53935) 12%, var(--bg-card));
border: 1px solid color-mix(in srgb, var(--color-danger, #e53935) 40%, transparent);
border-radius: var(--radius-md);
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
}
.offline-storage-full-banner span {
flex: 1;
}
.offline-storage-full-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
.offline-storage-full-dismiss:hover {
color: var(--text-primary);
}
/* ─── Player Bar ─── */
@@ -504,6 +632,15 @@
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
min-width: 2.5rem;
}
.player-time:first-child {
text-align: right;
}
.player-time:last-child {
text-align: left;
}
/* Volume section */
@@ -619,6 +756,12 @@
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0; /* allow 1fr row to shrink freely */
}
.queue-panel.queue-drop-active {
box-shadow: inset 0 0 0 2px var(--accent);
background: linear-gradient(var(--accent-dim), var(--accent-dim)), var(--bg-sidebar);
}
.queue-header {
@@ -749,13 +892,18 @@
}
.queue-current-track {
display: flex;
flex-direction: column;
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-track-body {
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--space-3);
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-cover {
@@ -803,21 +951,18 @@
}
.queue-current-tech {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
font-size: 9px;
font-family: monospace;
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.85);
padding: 3px 6px;
text-align: center;
background: var(--ctp-surface1);
color: var(--accent);
padding: 3px var(--space-4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
backdrop-filter: blur(8px);
}
.queue-divider {
+8328 -1457
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
/**
* Creates a slim, themed drag ghost and registers it via setDragImage.
* The element is cleaned up when the drag completes (dragend).
*
* On Linux (WebKitGTK), the GTK drag subsystem captures the drag image
* asynchronously removing the element in the same tick or via
* setTimeout(, 0) causes the image to be gone before GTK reads it,
* which makes GTK treat the entire drag as invalid (forbidden cursor).
* Using a dragend listener ensures the element persists for the full
* duration of the drag operation on all platforms.
*
* @param dataTransfer - the event's dataTransfer object
* @param label - primary text (track/album title)
* @param opts.coverUrl - optional thumbnail URL (shown as 24×24 image)
*/
export function setDragGhost(
dataTransfer: DataTransfer,
label: string,
opts: { coverUrl?: string } = {},
): void {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed',
'left:-9999px',
'top:-9999px',
'display:flex',
'align-items:center',
'gap:8px',
`padding:0 14px 0 ${opts.coverUrl ? '6px' : '10px'}`,
'height:34px',
'max-width:240px',
'border-radius:17px',
'background:var(--bg-card,#fff)',
'border:1px solid var(--border,rgba(0,0,0,.12))',
'border-left:3px solid var(--accent,#888)',
'box-shadow:0 4px 20px rgba(0,0,0,.22)',
'font-family:var(--font-ui,sans-serif)',
'font-size:13px',
'font-weight:500',
'color:var(--text-primary,#222)',
'pointer-events:none',
'white-space:nowrap',
'overflow:hidden',
'z-index:99999',
].join(';');
if (opts.coverUrl) {
const img = document.createElement('img');
img.src = opts.coverUrl;
img.style.cssText = 'width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;';
el.appendChild(img);
} else {
const dot = document.createElement('span');
dot.style.cssText = 'width:6px;height:6px;border-radius:50%;background:var(--accent,#888);flex-shrink:0;';
el.appendChild(dot);
}
const text = document.createElement('span');
text.textContent = label;
text.style.cssText = 'overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;';
el.appendChild(text);
document.body.appendChild(el);
dataTransfer.setDragImage(el, 20, 17);
// Clean up the ghost element when the drag ends, not immediately.
// This is critical for Linux/WebKitGTK where the drag image is
// captured asynchronously by GTK — removing it sooner causes
// the "forbidden" cursor and blocks the entire drop operation.
const cleanup = () => {
el.remove();
document.removeEventListener('dragend', cleanup);
};
document.addEventListener('dragend', cleanup, { once: true });
}
+283
View File
@@ -0,0 +1,283 @@
import { writeFile } from '@tauri-apps/plugin-fs';
import { downloadDir, join } from '@tauri-apps/api/path';
import { getAlbumList, buildCoverArtUrl } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import type { SubsonicAlbum } from '../api/subsonic';
// Catppuccin Macchiato palette
const M = {
crust: '#181926',
mantle: '#1e2030',
base: '#24273a',
surface0: '#363a4f',
surface1: '#494d64',
surface2: '#5b6078',
text: '#cad3f5',
subtext1: '#b8c0e0',
subtext0: '#a5adcb',
mauve: '#c6a0f6',
lavender: '#b7bdf8',
overlay2: '#939ab7',
};
const W = 1080;
const PAD = 56;
const COVER_SIZE = 52;
const ROW_H = 72;
const COVER_PAD = (ROW_H - COVER_SIZE) / 2;
const TEXT_X = PAD + COVER_SIZE + 18;
const TEXT_W = W - TEXT_X - PAD;
const HEADER_H = 260;
const FOOTER_H = 72;
const MAX_PER_PAGE = 20;
function clampText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) return text;
let t = text;
while (ctx.measureText(t + '…').width > maxW && t.length > 0) t = t.slice(0, -1);
return t + '…';
}
async function loadImage(url: string): Promise<ImageBitmap | null> {
try {
const res = await fetch(url);
if (!res.ok) return null;
return await createImageBitmap(await res.blob());
} catch { return null; }
}
async function loadLogo(): Promise<HTMLImageElement | null> {
try {
const res = await fetch('/psysonic-inapp-logo.svg');
if (!res.ok) return null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
return new Promise(resolve => {
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
} catch { return null; }
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
async function renderPage(
albums: SubsonicAlbum[],
covers: (ImageBitmap | null)[],
logo: HTMLImageElement | null,
now: Date,
totalCount: number,
pageNum: number,
totalPages: number,
globalOffset: number,
): Promise<Blob> {
const H = HEADER_H + albums.length * ROW_H + FOOTER_H;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = M.base;
ctx.fillRect(0, 0, W, H);
const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_H);
headerGrad.addColorStop(0, M.mantle);
headerGrad.addColorStop(1, M.base);
ctx.fillStyle = headerGrad;
ctx.fillRect(0, 0, W, HEADER_H);
// Logo
const LOGO_H = 52;
const logoY = 44;
if (logo) {
const logoW = logo.naturalWidth && logo.naturalHeight
? Math.round(LOGO_H * (logo.naturalWidth / logo.naturalHeight))
: LOGO_H * 4;
ctx.drawImage(logo, W / 2 - logoW / 2, logoY, logoW, LOGO_H);
}
// Title
ctx.textAlign = 'center';
ctx.font = '700 42px system-ui, sans-serif';
ctx.fillStyle = M.text;
ctx.fillText('Die neuesten Alben', W / 2, logoY + LOGO_H + 52);
// Date + page indicator
const dateStr = now.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
const pageStr = totalPages > 1 ? ` · Teil ${pageNum} / ${totalPages}` : '';
ctx.font = '400 18px system-ui, sans-serif';
ctx.fillStyle = M.subtext0;
ctx.fillText(dateStr + pageStr, W / 2, logoY + LOGO_H + 82);
// Count badge (total, only on first page)
if (pageNum === 1) {
const badgeText = `${totalCount} ${totalCount !== 1 ? 'Alben' : 'Album'}`;
ctx.font = '600 13px system-ui, sans-serif';
const badgeW = ctx.measureText(badgeText).width + 24;
const badgeX = W / 2 - badgeW / 2;
const badgeY = logoY + LOGO_H + 102;
roundRect(ctx, badgeX, badgeY, badgeW, 24, 12);
ctx.fillStyle = M.surface0;
ctx.fill();
ctx.fillStyle = M.mauve;
ctx.fillText(badgeText, W / 2, badgeY + 16);
}
// Divider
ctx.strokeStyle = M.surface1;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, HEADER_H - 16);
ctx.lineTo(W - PAD, HEADER_H - 16);
ctx.stroke();
// Album rows
for (let i = 0; i < albums.length; i++) {
const album = albums[i];
const cover = covers[i];
const rowY = HEADER_H + i * ROW_H;
const coverY = rowY + COVER_PAD;
const globalIdx = globalOffset + i;
if (i % 2 === 0) {
ctx.fillStyle = 'rgba(54,58,79,0.35)';
ctx.fillRect(0, rowY, W, ROW_H);
}
// Index
ctx.textAlign = 'right';
ctx.font = '500 13px system-ui, sans-serif';
ctx.fillStyle = M.surface2;
ctx.fillText(String(globalIdx + 1), PAD - 12, coverY + COVER_SIZE / 2 + 5);
// Cover
roundRect(ctx, PAD, coverY, COVER_SIZE, COVER_SIZE, 8);
ctx.fillStyle = M.surface0;
ctx.fill();
if (cover) {
ctx.save();
roundRect(ctx, PAD, coverY, COVER_SIZE, COVER_SIZE, 8);
ctx.clip();
ctx.drawImage(cover, PAD, coverY, COVER_SIZE, COVER_SIZE);
ctx.restore();
} else {
ctx.font = '28px system-ui';
ctx.fillStyle = M.surface2;
ctx.textAlign = 'center';
ctx.fillText('♪', PAD + COVER_SIZE / 2, coverY + COVER_SIZE / 2 + 10);
}
// Text row
ctx.textAlign = 'left';
ctx.font = '600 17px system-ui, sans-serif';
const lineY = coverY + COVER_SIZE / 2 + 6;
const sep = ' — ';
const artistClamp = clampText(ctx, album.artist, TEXT_W * 0.42);
const artistW = ctx.measureText(artistClamp).width;
const sepW = ctx.measureText(sep).width;
const remaining = TEXT_W - artistW - sepW;
const albumClamp = clampText(ctx, album.name, remaining * 0.65);
const albumW = ctx.measureText(albumClamp).width;
ctx.fillStyle = M.mauve;
ctx.fillText(artistClamp, TEXT_X, lineY);
ctx.fillStyle = M.overlay2;
ctx.fillText(sep, TEXT_X + artistW, lineY);
ctx.fillStyle = M.text;
ctx.fillText(albumClamp, TEXT_X + artistW + sepW, lineY);
if (album.year || album.genre) {
ctx.font = '400 15px system-ui, sans-serif';
let cx = TEXT_X + artistW + sepW + albumW;
if (album.year) {
ctx.fillStyle = M.subtext0;
const yearPart = ` (${album.year})`;
ctx.fillText(yearPart, cx, lineY);
cx += ctx.measureText(yearPart).width;
}
if (album.genre) {
ctx.fillStyle = M.subtext0;
const dashPart = ' — ';
ctx.fillText(dashPart, cx, lineY);
cx += ctx.measureText(dashPart).width;
ctx.fillStyle = M.lavender;
ctx.fillText(clampText(ctx, album.genre, TEXT_X + TEXT_W - cx), cx, lineY);
}
}
if (i < albums.length - 1) {
ctx.strokeStyle = M.surface0;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, rowY + ROW_H);
ctx.lineTo(W - PAD, rowY + ROW_H);
ctx.stroke();
}
}
// Footer
ctx.textAlign = 'center';
ctx.font = '400 13px system-ui, sans-serif';
ctx.fillStyle = M.overlay2;
ctx.fillText('www.psysonic.de', W / 2, H - FOOTER_H / 2 + 6);
return new Promise(resolve => canvas.toBlob(b => resolve(b!), 'image/png'));
}
export async function exportNewAlbumsImage(since: number): Promise<{ count: number; paths: string[] } | null> {
const albums = await getAlbumList('newest', 500);
if (albums.length === 0) return null;
const newAlbums = since > 0
? albums.filter(a => a.created && new Date(a.created).getTime() >= since)
: albums;
if (newAlbums.length === 0) return null;
newAlbums.sort((a, b) => a.artist.localeCompare(b.artist, 'de') || a.name.localeCompare(b.name, 'de'));
// Chunk into pages
const pages: SubsonicAlbum[][] = [];
for (let i = 0; i < newAlbums.length; i += MAX_PER_PAGE) {
pages.push(newAlbums.slice(i, i + MAX_PER_PAGE));
}
const now = new Date();
const logo = await loadLogo();
const { downloadFolder } = useAuthStore.getState();
const folder = downloadFolder || await downloadDir();
const timestamp = now.toISOString().slice(0, 10);
const paths: string[] = [];
for (let p = 0; p < pages.length; p++) {
const page = pages[p];
const covers = await Promise.all(
page.map(a => a.coverArt ? loadImage(buildCoverArtUrl(a.coverArt, 160)) : Promise.resolve(null))
);
const blob = await renderPage(page, covers, logo, now, newAlbums.length, p + 1, pages.length, p * MAX_PER_PAGE);
const suffix = pages.length > 1 ? `-${p + 1}` : '';
const filename = `psysonic-new-albums-${timestamp}${suffix}.png`;
const filePath = await join(folder, filename);
await writeFile(filePath, new Uint8Array(await blob.arrayBuffer()));
paths.push(filePath);
}
return { count: newAlbums.length, paths };
}
+87 -4
View File
@@ -1,3 +1,5 @@
import { useAuthStore } from '../store/authStore';
const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images';
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -25,7 +27,7 @@ function releaseFetchSlot(): void {
if (next) { activeFetches++; next(); }
}
function evictIfNeeded(): void {
function evictMemoryIfNeeded(): void {
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
const oldestKey = objectUrlCache.keys().next().value;
if (!oldestKey) break;
@@ -73,6 +75,48 @@ async function getBlob(key: string): Promise<Blob | null> {
}
}
/** Evicts oldest IDB entries until total blob size is below maxBytes. Fire-and-forget. */
async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
try {
const database = await openDB();
const entries: Array<{ key: string; timestamp: number; size: number }> = await new Promise(resolve => {
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
resolve(
(req.result ?? []).map((e: { key: string; timestamp: number; blob: Blob }) => ({
key: e.key,
timestamp: e.timestamp,
size: e.blob?.size ?? 0,
})),
);
};
req.onerror = () => resolve([]);
});
let total = entries.reduce((acc, e) => acc + e.size, 0);
if (total <= maxBytes) return;
// Oldest first
entries.sort((a, b) => a.timestamp - b.timestamp);
const tx = database.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const entry of entries) {
if (total <= maxBytes) break;
store.delete(entry.key);
// Also purge from memory cache
const objUrl = objectUrlCache.get(entry.key);
if (objUrl) {
URL.revokeObjectURL(objUrl);
objectUrlCache.delete(entry.key);
}
total -= entry.size;
}
} catch {
// Ignore
}
}
async function putBlob(key: string, blob: Blob): Promise<void> {
try {
const database = await openDB();
@@ -82,11 +126,50 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
// Enforce disk limit after write (fire-and-forget)
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
evictDiskIfNeeded(maxBytes);
} catch {
// Ignore write errors
}
}
/** Returns the total size in bytes of all blobs stored in IndexedDB. */
export async function getImageCacheSize(): Promise<number> {
try {
const database = await openDB();
return new Promise(resolve => {
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
const entries: Array<{ blob: Blob }> = req.result ?? [];
resolve(entries.reduce((acc, e) => acc + (e.blob?.size ?? 0), 0));
};
req.onerror = () => resolve(0);
});
} catch {
return 0;
}
}
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
export async function clearImageCache(): Promise<void> {
for (const url of objectUrlCache.values()) {
URL.revokeObjectURL(url);
}
objectUrlCache.clear();
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
// Ignore
}
}
/**
* Returns a cached object URL for an image.
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
@@ -104,7 +187,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
if (blob) {
const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
evictMemoryIfNeeded();
return objUrl;
}
@@ -114,10 +197,10 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl;
const newBlob = await resp.blob();
putBlob(cacheKey, newBlob); // fire-and-forget
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
evictMemoryIfNeeded();
return objUrl;
} catch {
return fetchUrl;
+6 -1
View File
@@ -20,7 +20,12 @@ function fadeOut(setVolume: (v: number) => void, from: number, durationMs: numbe
export async function playAlbum(albumId: string): Promise<void> {
const albumData = await getAlbum(albumId);
const tracks = albumData.songs.map(songToTrack);
const albumGenre = albumData.album.genre;
const tracks = albumData.songs.map(s => {
const track = songToTrack(s);
if (!track.genre && albumGenre) track.genre = albumGenre;
return track;
});
if (!tracks.length) return;
const store = usePlayerStore.getState();