mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 07:45:45 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43c656dfc3 | |||
| 74b519f9f5 | |||
| 3d03b8d5a1 | |||
| d6f6e6466c | |||
| 95cdbc7fc7 | |||
| 42863877f6 | |||
| 7ed0fa4914 | |||
| 4f8e7d7bc7 | |||
| bb56269cd1 | |||
| 29a4363dca | |||
| e1d27798eb | |||
| b35539d3cf | |||
| a1b3022140 | |||
| b6fb66c46d | |||
| 936e548f40 | |||
| b67c198227 | |||
| 65a828e3fa | |||
| 6bdd6f3a59 | |||
| d62bffd082 | |||
| ff706104ab | |||
| 0abef4b266 | |||
| 3effad0830 | |||
| 361e9cfdb3 | |||
| 5516d95b52 | |||
| d927ef2082 | |||
| 867c5fbd3e | |||
| e550340565 | |||
| 57b70e6154 |
@@ -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
|
||||
|
||||
@@ -34,3 +34,9 @@ src-tauri/target/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
+449
@@ -5,6 +5,455 @@ 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.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`. Blue–gold 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 500–1000+ 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
|
||||
|
||||
- **SVG Logo**: The Psysonic wordmark is now an inline SVG with a theme-adaptive gradient (`--accent` → `--ctp-blue`), matching the app's visual identity across all 47 themes. The collapsed sidebar shows a standalone P-icon with the same gradient.
|
||||
- **Player Bar — Marquee**: Song title and artist name scroll smoothly when the text overflows the fixed-width track info area, pause briefly, then jump back and repeat.
|
||||
- **Player Bar — Volume Tooltip**: A floating percentage label appears above the volume slider on hover and updates live while dragging.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sidebar — Collapse button**: Moved from the brand header to a small circular hover-tab on the right edge of the sidebar. Hidden until you hover over the sidebar, keeping the logo area uncluttered.
|
||||
- **Player Bar — Layout**: Track info area is now a fixed 320 px width. Waveform section has increased margins on both sides for better visual separation between controls, waveform, and volume.
|
||||
- **Settings**: Server tab is now the default when opening Settings.
|
||||
- **Crossfade**: Experimental badge removed — considered stable.
|
||||
- **Help page**: Added entries for Lyrics, Configurable Keybindings, and Font Picker. Theme count corrected to 47 themes across 7 groups.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Global shortcuts — double-fire**: Pressing a global shortcut (e.g. `Ctrl+Alt+→`) was triggering the action twice. Root cause: `on_shortcut()` in `tauri_plugin_global_shortcut` accumulates handlers per shortcut across JS HMR reloads. Fixed with a Rust-side `ShortcutMap` state that makes `register_global_shortcut` idempotent.
|
||||
- **W98 theme**: Comprehensive contrast fixes across all interactive elements — hover states, buttons, queue items, settings panels, and toggles now use silver-grey (`#e0e0e0`) text on navy (`#000080`) backgrounds.
|
||||
- **Help page**: Removed orphaned translation key that was rendering as raw text under the Playback section.
|
||||
|
||||
### Beta
|
||||
|
||||
- **Global Shortcuts** (Settings → Global Shortcuts): System-wide keyboard shortcuts that trigger playback actions while Psysonic is in the background. Functional on all platforms, but edge cases with certain key combinations or OS-level conflicts may still occur.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Synchronized Lyrics**: Lyrics pane integrated into the Queue sidebar, powered by [LRCLIB](https://lrclib.net) — no API key required. Shows time-synced lyrics with auto-scroll and active-line highlighting; falls back to plain text when synced lyrics are unavailable. Access via the microphone icon in the player bar, fullscreen player, or Now Playing page.
|
||||
|
||||
#### 15 New Themes
|
||||
|
||||
**Games** (new group — 6 themes):
|
||||
- **Ascalon**: Dark stone fantasy inspired by Guild Wars 1. Near-black base, gold accent (`#d4af37`).
|
||||
- **Azerothian Gold**: World of Warcraft inspired. Charcoal base, warm gold accent (`#c19e67`).
|
||||
- **Grand Theft Audio**: GTA-inspired night city aesthetic. Pure black base, green accent (`#57b05a`).
|
||||
- **Lambda 17**: Half-Life inspired. Deep blue-black base, amber accent (`#ff9d00`).
|
||||
- **NightCity 2077**: Cyberpunk 2077 inspired. Near-total black base, neon yellow accent (`#FCEE0A`).
|
||||
- **V-Tactical**: Battlefield inspired. Gunmetal base, burnt orange accent (`#ff8a00`).
|
||||
|
||||
**Series** (new group — 3 themes):
|
||||
- **A Theme of Ice and Fire**: Game of Thrones inspired. Cold dark navy base, ice blue accent (`#70a1ff`).
|
||||
- **D'oh-matic**: The Simpsons inspired. Cream/yellow light base, blue accent (`#1F75FE`).
|
||||
- **Heisenberg**: Breaking Bad inspired. Dark desaturated green base, crystal blue accent (`#3fe0ff`).
|
||||
|
||||
**Movies** (2 additions):
|
||||
- **Imperial Sith**: Star Wars dark side. Pure black base, red accent (`#e60000`).
|
||||
- **Order of the Phoenix**: Harry Potter inspired. Deep charcoal base, ember-orange accent (`#e63900`).
|
||||
|
||||
**Operating Systems** (1 addition):
|
||||
- **W98**: Windows 98 teal desktop aesthetic. Classic teal background, silver card, navy accent (`#000080`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Last.fm integration**: Promoted out of beta — scrobbling, Now Playing, love/unlove, Similar Artists, and top stats are considered stable.
|
||||
- **Crossfade**: No longer marked experimental. Stable on Windows and Linux; macOS under observation.
|
||||
- **Gapless playback**: Experimental badge removed — considered stable.
|
||||
- **Theme picker — groups reorganised**: Catppuccin, Nord, and Retro (Gruvbox) merged into a single **Open Source Classics** group. Streaming themes (Spotless, DZR, Cupertino Beats) moved into **Psysonic Themes — Mediaplayer**. The app now ships **47 themes** across **7 groups**.
|
||||
- **Tokyo Night themes removed**: `tokyo-night`, `tokyo-night-storm`, and `tokyo-night-light` retired to make room for the new groups.
|
||||
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
|
||||
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Text selection on double-click**: Double-clicking song titles or anywhere in the UI no longer accidentally selects text. `user-select: none` applied globally; re-enabled for bio/description text areas.
|
||||
- **Middle Earth theme — star buttons**: Active favourite star in the album tracklist and album header was barely visible (gold on parchment, ~1.4:1 contrast). Both active and inactive states now use darker brown tones with proper contrast.
|
||||
- **Middle Earth theme — play button hover**: Hovering the primary play/pause button no longer makes the icon invisible (gold icon on gold background).
|
||||
|
||||
## [1.11.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
#### Five New Themes — Movies
|
||||
- **Middle Earth**: Warm parchment light theme. Cream/beige background, dark ebony player and sidebar, gold accent (`#d4af37`). Georgia serif for track names, subtle noise texture.
|
||||
- **Morpheus**: Pure black terminal aesthetic inspired by The Matrix. Phosphor green accent (`#00ff41`), monospace font.
|
||||
- **Pandora**: Deep bioluminescent navy inspired by Avatar. Cyan accent (`#00f2ff`), large radii, glow effects.
|
||||
- **Stark HUD**: Near-black tactical HUD inspired by Iron Man. Cyan accent, JetBrains Mono, uppercase track name.
|
||||
- **Blade**: Deep black with blood-red accent (`#b30000`). Sharp radii, uppercase track name.
|
||||
- All five themes in a new **Movies** group in the theme picker.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
|
||||
- **Settings — Appearance**: Language selector moved to the top of the tab, above Theme and Font.
|
||||
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
|
||||
- **Default font**: Changed from Inter to **Lexend** for new installations.
|
||||
- **Gapless playback**: Experimental badge removed — gapless is now considered stable.
|
||||
- **Now Playing — background**: Ken Burns animation (40 s, subtle scale + translate). Background blur increased to eliminate JPEG block artefacts at high blur values.
|
||||
- **Now Playing — Similar Artists**: Tag cloud redesigned into 2 rows with varied font sizes and vertical offsets for a natural look.
|
||||
- **Statistics**: "Now Playing" indicator rendered as a styled badge matching the app's badge style.
|
||||
|
||||
## [1.10.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
#### Three New Themes (Streaming Series)
|
||||
- **Spotless**: Flat dark theme inspired by modern music streaming. Pitch-black sidebar (`#000000`), dark-grey app background (`#121212`), Spotify-green accent (`#1ED760`). White play button, green hover on primary actions.
|
||||
- **DZR**: Flat light theme inspired by Deezer's modern redesign. White base, light-grey sidebar (`#F5F5F7`), purple accent (`#A238FF`). Crisp typography, large rounded radii.
|
||||
- **Cupertino Beats**: Apple Music-inspired dark theme. Near-black base (`#1c1c1e`), frosted-glass sidebar and player bar with heavy `backdrop-filter`, red accent (`#fa243c`). Active nav links styled with `accent-dim` background.
|
||||
- All three themes added to the **Psysonic Themes — Mediaplayer** group in the theme picker.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Favourite/Unfavourite toggle**: Right-clicking a song, album, or artist that is already starred now shows "Remove from Favourites" and calls `unstar()` correctly. Previously always showed "Add to Favourites" regardless of starred state.
|
||||
- `Track` interface gained `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
|
||||
- `starredOverrides: Record<string, boolean>` added to `playerStore` — updated immediately on star/unstar so the context menu and tracklist star icons reflect changes without a page reload.
|
||||
- **Home page — Artist Discovery**: Replaced card grid (which loaded artist images and caused performance issues) with lightweight pill-buttons — same `artist-ext-link` style as the "Similar Artists" section on artist pages. No image loading, instant render.
|
||||
- **Now Playing page**: Queue sidebar is no longer automatically hidden when entering the Now Playing page. It now behaves like all other pages and respects the user's current queue visibility setting.
|
||||
- **Random Mix filter panel**: Background now correctly uses `--bg-card` instead of the undefined `--bg-elevated` token, which caused the panel to render transparent in most themes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Home page layout**: Section order is now: Recently Added → Discover → Artist Discovery → Starred → Most Played.
|
||||
|
||||
## [1.9.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Three New Themes
|
||||
- **Neon Drift**: Deep midnight-blue background (`#12132c`) with electric cyan accent (`#00f2ff`) — subtle synthwave/cyberpunk aesthetic. Glowing player track name, cyan-glow nav active state, neon-lit primary buttons, glowing range slider thumb.
|
||||
- **Cupertino Light**: macOS Ventura-inspired light theme. Clean white base, Apple-grey sidebar (`#f2f2f7`), Apple blue accent (`#0071e3`). Frosted-glass sidebar and player bar with `backdrop-filter: blur`. Solid blue pill nav active (white text, no left border).
|
||||
- **Cupertino Dark**: macOS Ventura-inspired dark theme. Space Grey base (`#1e1e1f`), dark frosted sidebar, vibrant blue accent (`#007aff`). Same pill nav active as Cupertino Light. Solid blue Play/Pause button with glow.
|
||||
|
||||
#### New Theme Group: Betriebssysteme
|
||||
- OS-aesthetic themes are now consolidated into one group: **Cupertino Light**, **Cupertino Dark**, **Aero Glass**, **Luna Teal**.
|
||||
- **Psysonic Themes** and **Psysonic Themes — Mediaplayer** moved to the top of the theme picker.
|
||||
|
||||
#### Configurable Keybindings
|
||||
- New `keybindingsStore` with 10 bindable actions: Play/Pause, Next, Previous, Volume Up/Down, Seek ±10 s, Toggle Queue, Fullscreen Player, Native Fullscreen.
|
||||
- Rebind any action in **Settings → Keybindings** — click the key badge, press any key, saved immediately to `localStorage`.
|
||||
- Defaults: `Space` = Play/Pause, `F11` = Native Fullscreen. All other actions unbound by default.
|
||||
|
||||
#### Font Picker
|
||||
- 10 UI fonts selectable in **Settings → Appearance**: Inter, Outfit, DM Sans, Nunito, Rubik, Space Grotesk, Figtree, Manrope, Plus Jakarta Sans, Lexend.
|
||||
- Persisted in `localStorage` (`psysonic_font`), applied via `data-font` attribute on `<html>`.
|
||||
|
||||
#### Home Page — Instant Play
|
||||
- **Album cards**: "Details" button replaced with a **Play** button — clicking plays the album immediately with a smooth 700 ms fade-out of the current track.
|
||||
- **Hero**: "Play Album" button now starts playback directly (with fade-out) instead of navigating to the album detail page.
|
||||
- Fade-out implemented via `playAlbum.ts` utility: fades volume to 0 over 700 ms, restores volume in the store (no Rust side-effect) before handing off to `playTrack`.
|
||||
|
||||
#### Now Playing Page — Layout & Readability
|
||||
- **3-column hero layout**: album cover + info (left, `flex: 1`) — EQ bars (centre, fixed width) — tag cloud (right, `flex: 1`). EQ bars are now truly centred regardless of content length on either side.
|
||||
- **Background**: increased brightness from `0.25` to `0.55`, reduced overlay opacity from `0.55` to `0.38` — background art is now visible instead of near-black.
|
||||
- **Text contrast**: track times, card links (artist/album), and section title opacity all increased for better readability on the blurred background.
|
||||
|
||||
### Changed
|
||||
|
||||
#### Theme Renames — Trademark-Safe Names
|
||||
All media-player and OS-themed theme IDs and labels have been renamed to avoid potential trademark conflicts:
|
||||
|
||||
| Old Name | New Name |
|
||||
|---|---|
|
||||
| Classic Winamp | WnAmp |
|
||||
| Musicmatch Jukebox | Navy Jukebox |
|
||||
| WMP8 Classic | Cobalt Media |
|
||||
| PowerDVD Classic | Onyx Cinema |
|
||||
| Win7 Aero | Aero Glass |
|
||||
| WinXP Luna | Luna Teal |
|
||||
|
||||
> **Note**: If you had one of these themes selected, your preference will reset to Mocha on first launch. Re-select your preferred theme in Settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux — ALSA underruns**: `PIPEWIRE_LATENCY` (`4096/48000` ≈ 85 ms) and `PULSE_LATENCY_MSEC` (`85`) are now set before audio stream creation, reducing the frequency of ALSA `snd_pcm_recover` underrun events on PipeWire systems. Existing user-set values are respected.
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,213 +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 (21 themes), applied as `data-theme` on `<html>` |
|
||||
| `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 1–10 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
|
||||
21 themes are available, selectable in Settings via `ThemePicker` (grouped). `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 Classic Winamp for orange volume, yellow accent elsewhere).
|
||||
|
||||
| Theme | Group | Style | Accent |
|
||||
|---|---|---|---|
|
||||
| `mocha` | Catppuccin | dark | Mauve |
|
||||
| `macchiato` | Catppuccin | medium-dark | Mauve |
|
||||
| `frappe` | Catppuccin | medium | Mauve |
|
||||
| `latte` | Catppuccin | light | Mauve |
|
||||
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
|
||||
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
|
||||
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
|
||||
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
|
||||
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
|
||||
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
|
||||
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
|
||||
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
|
||||
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
|
||||
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
|
||||
| `tokyo-night` | Tokyo Night | dark blue | Purple `#7aa2f7` |
|
||||
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Purple `#7aa2f7` |
|
||||
| `tokyo-night-light` | Tokyo Night | light | Purple `#7aa2f7` |
|
||||
| `classic-winamp` | Psysonic | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
|
||||
| `poison` | Psysonic | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
|
||||
| `nucleo` | Psysonic | warm brass/cream light | Gold `#c8a860` |
|
||||
| `psychowave` | Psysonic | deep violet synthwave | Purple `#a06ae0` |
|
||||
|
||||
**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" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
|
||||
- **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`).
|
||||
- **Version**: 1.8.0
|
||||
@@ -22,18 +22,21 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 21 deeply integrated themes — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, and Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) — 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** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎵 **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.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🎼 **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).
|
||||
|
||||
@@ -44,35 +47,33 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback *(experimental)*
|
||||
- [x] MilkDrop/Butterchurn visualizer
|
||||
- [x] Gapless playback
|
||||
- [x] Waveform seekbar
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with keyword filter & Super Genre mix
|
||||
- [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave)
|
||||
- [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)
|
||||
|
||||
### 🚧 In Progress
|
||||
- [ ] **Last.fm integration** — stabilising, moving out of beta
|
||||
- [ ] **Gapless playback** — edge case stabilisation
|
||||
- [ ] **Crossfade** — stability improvements
|
||||
- [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.
|
||||
- **Linux (Wayland — drag & drop cursor)**: On some Wayland compositors the drag cursor may show no visual indicator during DnD. This is a WebKitGTK limitation unrelated to the drop operation itself, which always works correctly.
|
||||
- **FLAC without SEEKTABLE**: FLAC files that were encoded without a SEEKTABLE index cannot be seeked. The seek silently fails; playback continues from the current position.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
|
||||
Generated
+5
-74
@@ -1,24 +1,21 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"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",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -1496,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",
|
||||
@@ -1667,16 +1655,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1724,27 +1702,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
|
||||
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn-presets": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
|
||||
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2",
|
||||
"lodash": "^4.17.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1807,14 +1764,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
@@ -1872,12 +1821,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecma-proposal-math-extensions": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
|
||||
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
@@ -2236,12 +2179,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2355,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": {
|
||||
@@ -2496,12 +2433,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.8.0",
|
||||
"version": "1.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,13 +15,10 @@
|
||||
"@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",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.8.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'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="115.549mm"
|
||||
height="130.30972mm"
|
||||
viewBox="0 0 115.549 130.30972"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="p-small.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"><inkscape:page
|
||||
x="0"
|
||||
y="0"
|
||||
width="115.549"
|
||||
height="130.30972"
|
||||
id="page2"
|
||||
margin="0"
|
||||
bleed="0" /></sodipodi:namedview><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Ebene 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(220.53237,27.789086)"><path
|
||||
style="fill:#ffffff"
|
||||
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
|
||||
id="path1" /></g></svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 1.5 MiB |
Generated
+500
-179
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.8.0"
|
||||
version = "1.21.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"] }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 @@
|
||||
{"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"]}}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+720
-78
File diff suppressed because it is too large
Load Diff
+309
-81
@@ -3,11 +3,18 @@
|
||||
|
||||
mod audio;
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
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 {
|
||||
@@ -54,14 +61,14 @@ async fn lastfm_request(
|
||||
client
|
||||
.get("https://ws.audioscrobbler.com/2.0/")
|
||||
.query(&map)
|
||||
.header("User-Agent", "psysonic/1.6.0")
|
||||
.header("User-Agent", "psysonic/1.13.0")
|
||||
.send()
|
||||
.await
|
||||
} else {
|
||||
client
|
||||
.post("https://ws.audioscrobbler.com/2.0/")
|
||||
.form(&map)
|
||||
.header("User-Agent", "psysonic/1.6.0")
|
||||
.header("User-Agent", "psysonic/1.13.0")
|
||||
.send()
|
||||
.await
|
||||
}.map_err(|e| e.to_string())?;
|
||||
@@ -76,117 +83,338 @@ async fn lastfm_request(
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
fn register_global_shortcut(
|
||||
app: tauri::AppHandle,
|
||||
shortcut_map: tauri::State<ShortcutMap>,
|
||||
shortcut: String,
|
||||
action: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
let mut map = shortcut_map.lock().unwrap();
|
||||
|
||||
// Idempotent: if this exact shortcut+action is already registered, skip.
|
||||
// This prevents on_shortcut() from accumulating duplicate handlers when
|
||||
// registerAll() is called again after a JS HMR reload or StrictMode double-effect.
|
||||
if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Unregister any existing OS grab for this shortcut before re-registering.
|
||||
if let Ok(s) = shortcut.parse::<Shortcut>() {
|
||||
let _ = app.global_shortcut().unregister(s);
|
||||
}
|
||||
map.insert(shortcut.clone(), action.clone());
|
||||
drop(map); // release lock before the blocking OS call
|
||||
|
||||
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
|
||||
app.global_shortcut()
|
||||
.on_shortcut(parsed, move |app, _shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let event_name = match action.as_str() {
|
||||
"play-pause" => "media:play-pause",
|
||||
"next" => "media:next",
|
||||
"prev" => "media:prev",
|
||||
"volume-up" => "media:volume-up",
|
||||
"volume-down" => "media:volume-down",
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.emit(event_name, ());
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn unregister_global_shortcut(
|
||||
app: tauri::AppHandle,
|
||||
shortcut_map: tauri::State<ShortcutMap>,
|
||||
shortcut: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
|
||||
shortcut_map.lock().unwrap().remove(&shortcut);
|
||||
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {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();
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.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
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
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");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.8.0",
|
||||
"version": "1.23.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -23,7 +23,8 @@
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true,
|
||||
"dragDropEnabled": false
|
||||
"dragDropEnabled": false,
|
||||
"devtools": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -54,9 +55,13 @@
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+246
-41
@@ -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,23 +22,35 @@ 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 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';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
import { useKeybindingsStore } from './store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
@@ -56,6 +68,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();
|
||||
@@ -83,6 +115,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';
|
||||
});
|
||||
@@ -123,6 +164,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"
|
||||
@@ -132,9 +200,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">
|
||||
@@ -144,16 +212,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}
|
||||
@@ -170,13 +241,16 @@ 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 />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -196,74 +270,202 @@ 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();
|
||||
|
||||
// Spacebar → play/pause, F11 → window fullscreen
|
||||
// Configurable keybindings
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.code === 'F11') {
|
||||
e.preventDefault();
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
if (e.code !== 'Space') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
// Global shortcuts use modifier combos — skip in-app bindings for those
|
||||
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const { bindings } = useKeybindingsStore.getState();
|
||||
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
|
||||
|
||||
const action = (Object.entries(bindings) as [string, string | null][])
|
||||
.find(([, code]) => code === e.code)?.[0];
|
||||
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
|
||||
switch (action) {
|
||||
case 'play-pause': togglePlay(); break;
|
||||
case 'next': next(); break;
|
||||
case 'prev': previous(); break;
|
||||
case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break;
|
||||
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
|
||||
case 'seek-forward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
|
||||
break;
|
||||
}
|
||||
case 'seek-backward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.max(0, s.currentTime - 10));
|
||||
break;
|
||||
}
|
||||
case 'toggle-queue': toggleQueue(); break;
|
||||
case 'fullscreen-player': toggleFullscreen(); break;
|
||||
case 'native-fullscreen': {
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [togglePlay]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const unlisten: Array<() => void> = [];
|
||||
|
||||
listen('media:play-pause', () => togglePlay()).then(u => unlisten.push(u));
|
||||
listen('media:next', () => next()).then(u => unlisten.push(u));
|
||||
listen('media:prev', () => previous()).then(u => unlisten.push(u));
|
||||
listen('tray:play-pause', () => togglePlay()).then(u => unlisten.push(u));
|
||||
listen('tray:next', () => next()).then(u => unlisten.push(u));
|
||||
|
||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
||||
const win = getCurrentWindow();
|
||||
win.onCloseRequested(async (event) => {
|
||||
if (minimizeToTray) {
|
||||
event.preventDefault();
|
||||
await win.hide();
|
||||
} else {
|
||||
// If not minimizing to tray, we want to exit the app completely
|
||||
await invoke('exit_app');
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:next', () => next()],
|
||||
['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)); }],
|
||||
];
|
||||
for (const [event, handler] of handlers) {
|
||||
const u = await listen(event, handler);
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
}).then(u => unlisten.push(u));
|
||||
|
||||
return () => unlisten.forEach(u => u());
|
||||
}, [togglePlay, next, previous, minimizeToTray]);
|
||||
// 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 () => {
|
||||
await invoke('exit_app');
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
};
|
||||
|
||||
setup();
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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 />
|
||||
@@ -273,11 +475,14 @@ export default function App() {
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
<DragDropProvider>
|
||||
<AppShell />
|
||||
</DragDropProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface LrclibLyrics {
|
||||
syncedLyrics: string | null;
|
||||
plainLyrics: string | null;
|
||||
}
|
||||
|
||||
export interface LrcLine {
|
||||
time: number; // seconds
|
||||
text: string;
|
||||
}
|
||||
|
||||
export async function fetchLyrics(
|
||||
artist: string,
|
||||
title: string,
|
||||
album: string,
|
||||
duration: number,
|
||||
): Promise<LrclibLyrics | null> {
|
||||
const params = new URLSearchParams({
|
||||
artist_name: artist,
|
||||
track_name: title,
|
||||
album_name: album,
|
||||
duration: Math.round(duration).toString(),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://lrclib.net/api/get?${params}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
syncedLyrics: data.syncedLyrics ?? null,
|
||||
plainLyrics: data.plainLyrics ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
|
||||
if (!match) continue;
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseFloat(match[2]);
|
||||
const text = match[3].trim();
|
||||
lines.push({ time: mins * 60 + secs, text });
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
+23
-4
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { version } from '../../package.json';
|
||||
|
||||
// ─── Secure random salt ────────────────────────────────────────
|
||||
function secureRandomSalt(): string {
|
||||
@@ -13,7 +14,7 @@ function secureRandomSalt(): string {
|
||||
function getAuthParams(username: string, password: string) {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
@@ -51,6 +52,7 @@ export interface SubsonicAlbum {
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -124,6 +126,7 @@ export interface SubsonicArtistInfo {
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
@@ -165,7 +168,7 @@ export async function getAlbumList(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
@@ -227,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 {
|
||||
@@ -378,6 +392,11 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
|
||||
await api('createPlaylist.view', params);
|
||||
}
|
||||
|
||||
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> {
|
||||
await api('deletePlaylist.view', { id });
|
||||
}
|
||||
|
||||
@@ -1,18 +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
|
||||
@@ -26,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">
|
||||
@@ -47,21 +64,28 @@ 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"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
|
||||
aria-label={`Details zu ${album.name}`}
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
Details
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
|
||||
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AlbumCard);
|
||||
|
||||
@@ -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>
|
||||
@@ -191,7 +201,7 @@ export default function AlbumHeader({
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,21 +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,
|
||||
});
|
||||
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>
|
||||
@@ -111,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
|
||||
@@ -143,19 +151,17 @@ export default function AlbumTrackList({
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<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"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
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>
|
||||
@@ -177,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>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
|
||||
@@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic';
|
||||
import ArtistCardLocal from './ArtistCardLocal';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
artists: SubsonicArtist[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
|
||||
const { t } = useTranslation();
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
|
||||
// Auto-load trigger
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
triggerLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLoadMore = async () => {
|
||||
if (!onLoadMore || loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoadingMore(true);
|
||||
await onLoadMore();
|
||||
setLoadingMore(false);
|
||||
loadingRef.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,35 +43,19 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingMore && moreLink && (
|
||||
{moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<ArrowRight size={24} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -21,7 +21,7 @@ function sanitizeFilename(name: string): string {
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -53,6 +53,9 @@ export default function ContextMenu() {
|
||||
|
||||
const { type, item, queueIndex } = contextMenu;
|
||||
|
||||
const isStarred = (id: string, itemStarred?: string) =>
|
||||
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
|
||||
|
||||
const handleAction = async (action: () => void | Promise<void>) => {
|
||||
closeContextMenu();
|
||||
await action();
|
||||
@@ -61,15 +64,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);
|
||||
}
|
||||
@@ -126,16 +125,12 @@ 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);
|
||||
})}>
|
||||
{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>
|
||||
)}
|
||||
@@ -148,8 +143,13 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {t('contextMenu.favorite')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(song.id, song.starred);
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
@@ -181,8 +181,13 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
|
||||
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
@@ -199,8 +204,13 @@ export default function ContextMenu() {
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
|
||||
<Star size={14} /> {t('contextMenu.favoriteArtist')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -224,8 +234,13 @@ export default function ContextMenu() {
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {t('contextMenu.favorite')}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(song.id, song.starred);
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
@@ -14,7 +15,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
|
||||
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
|
||||
<img
|
||||
@@ -23,6 +24,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
|
||||
alt={alt}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VisualizerCanvas from './VisualizerCanvas';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -148,9 +148,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
|
||||
const [vizActive, setVizActive] = useState(false);
|
||||
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
@@ -182,55 +183,15 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
|
||||
{/* Layer 1 — blurred artist image OR visualizer */}
|
||||
{vizActive && currentTrack ? (
|
||||
<VisualizerCanvas
|
||||
trackId={currentTrack.id}
|
||||
nextPresetTrigger={nextPresetTrigger}
|
||||
onPresetName={setPresetName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
{/* Layer 1 — blurred artist image */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Visualizer toggle + preset controls */}
|
||||
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{vizActive && (
|
||||
<>
|
||||
{presetName && (
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{presetName}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => setNextPresetTrigger(n => n + 1)}
|
||||
aria-label={t('player.nextPreset')}
|
||||
data-tooltip={t('player.nextPreset')}
|
||||
style={{ color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
|
||||
onClick={() => setVizActive(v => !v)}
|
||||
aria-label={t('player.visualizer')}
|
||||
data-tooltip={t('player.visualizer')}
|
||||
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
<AudioWaveform size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
@@ -286,6 +247,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
-15
@@ -3,8 +3,9 @@ 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';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
|
||||
@@ -79,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) : '';
|
||||
@@ -98,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 */}
|
||||
@@ -119,12 +137,13 @@ 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
|
||||
className="hero-play-btn"
|
||||
id="hero-play-btn"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
@@ -132,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')}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
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 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);
|
||||
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const prevActive = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
setNotFound(false);
|
||||
setLoading(true);
|
||||
lineRefs.current = [];
|
||||
prevActive.current = -1;
|
||||
|
||||
fetchLyrics(
|
||||
currentTrack.artist ?? '',
|
||||
currentTrack.title,
|
||||
currentTrack.album ?? '',
|
||||
currentTrack.duration ?? 0,
|
||||
).then(result => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
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
|
||||
|
||||
const activeIdx = hasSynced
|
||||
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIdx < 0 || activeIdx === prevActive.current) return;
|
||||
prevActive.current = activeIdx;
|
||||
lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, [activeIdx]);
|
||||
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
<div className="lyrics-pane-empty">
|
||||
<p className="lyrics-status">{t('player.lyricsNotFound')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
|
||||
{hasSynced && (
|
||||
<div className="lyrics-synced">
|
||||
{syncedLines!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`lyrics-line${i === activeIdx ? ' active' : ''}`}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasSynced && plainLyrics && (
|
||||
<div className="lyrics-plain">
|
||||
{plainLyrics.split('\n').map((line, i) => (
|
||||
<p key={i} className="lyrics-plain-line">{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function MarqueeText({ text, className, style, onClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [scrollAmount, setScrollAmount] = useState(0);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
text.style.display = 'inline-block';
|
||||
const textWidth = text.getBoundingClientRect().width;
|
||||
text.style.display = '';
|
||||
const overflow = textWidth - container.clientWidth;
|
||||
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (containerRef.current) ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [text, measure]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`marquee-wrap${className ? ` ${className}` : ''}`}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={scrollAmount > 0 ? 'marquee-scroll' : ''}
|
||||
style={scrollAmount > 0 ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function PSmallLogo({ className, style }: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--ctp-blue)" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
transform="translate(220.53237,27.789086)"><path
|
||||
fill="url(#psmallGrad)"
|
||||
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
|
||||
id="path1" /></g></svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -11,6 +12,8 @@ import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -23,11 +26,15 @@ export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const [showVolPct, setShowVolPct] = useState(false);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
@@ -72,22 +79,18 @@ export default function PlayerBar() {
|
||||
)}
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
<div
|
||||
<MarqueeText
|
||||
text={currentTrack?.title ?? t('player.noTitle')}
|
||||
className="player-track-name"
|
||||
data-tooltip={currentTrack?.title ?? ''}
|
||||
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>
|
||||
{currentTrack?.title ?? t('player.noTitle')}
|
||||
</div>
|
||||
<div
|
||||
/>
|
||||
<MarqueeText
|
||||
text={currentTrack?.artist ?? '—'}
|
||||
className="player-track-artist"
|
||||
data-tooltip={currentTrack?.artist ?? ''}
|
||||
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>
|
||||
{currentTrack?.artist ?? '—'}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{currentTrack && lastfmSessionKey && (
|
||||
<button
|
||||
@@ -116,7 +119,7 @@ export default function PlayerBar() {
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
@@ -141,6 +144,16 @@ export default function PlayerBar() {
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Lyrics Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={15} />
|
||||
</button>
|
||||
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
@@ -161,22 +174,31 @@ export default function PlayerBar() {
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
className="player-volume-slider"
|
||||
/>
|
||||
<div className="player-volume-slider-wrap">
|
||||
{showVolPct && (
|
||||
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
className="player-volume-slider"
|
||||
onMouseEnter={() => setShowVolPct(true)}
|
||||
onMouseLeave={() => setShowVolPct(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EQ Popup */}
|
||||
{eqOpen && (
|
||||
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
|
||||
{eqOpen && createPortal(
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
||||
<div className="eq-popup">
|
||||
@@ -188,7 +210,8 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
<Equalizer />
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
</footer>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+294
-188
@@ -1,11 +1,14 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } 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 {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -57,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);
|
||||
@@ -78,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>
|
||||
@@ -108,19 +130,35 @@ 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();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
@@ -129,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);
|
||||
@@ -138,6 +177,10 @@ export default function QueuePanel() {
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -155,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 = () => {
|
||||
@@ -179,170 +285,131 @@ 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 && (() => {
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const h = Math.floor(totalSecs / 3600);
|
||||
const m = Math.floor((totalSecs % 3600) / 60);
|
||||
const s = totalSecs % 60;
|
||||
const dur = h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
const remainingSecs = Math.max(0,
|
||||
(queue[queueIndex]?.duration ?? 0) - currentTime
|
||||
+ queue.slice(queueIndex + 1).reduce((acc, t) => acc + (t.duration || 0), 0)
|
||||
);
|
||||
const fmt = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||
return (
|
||||
<span style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
|
||||
<span
|
||||
onClick={() => setShowRemainingTime(v => !v)}
|
||||
data-tooltip={showRemainingTime ? t('queue.showTotal') : t('queue.showRemaining')}
|
||||
style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</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" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.artist}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>{currentTrack.artist}</div>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.album}
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="queue-toolbar">
|
||||
{activeTab === 'queue' ? (<>
|
||||
<div className="queue-toolbar">
|
||||
<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} />
|
||||
@@ -353,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')}
|
||||
>
|
||||
@@ -368,6 +435,7 @@ export default function QueuePanel() {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
@@ -414,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' };
|
||||
@@ -439,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">
|
||||
@@ -461,41 +541,67 @@ export default function QueuePanel() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>) : (
|
||||
<LyricsPane currentTrack={currentTrack} />
|
||||
)}
|
||||
|
||||
<div className="queue-tab-bar">
|
||||
<button
|
||||
className={`queue-tab-btn${activeTab === 'queue' ? ' active' : ''}`}
|
||||
onClick={() => setTab('queue')}
|
||||
aria-label={t('queue.title')}
|
||||
>
|
||||
<ListMusic size={14} />
|
||||
{t('queue.title')}
|
||||
</button>
|
||||
<button
|
||||
className={`queue-tab-btn${activeTab === 'lyrics' ? ' active' : ''}`}
|
||||
onClick={() => setTab('lyrics')}
|
||||
aria-label={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
{t('player.lyrics')}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
+51
-19
@@ -1,17 +1,17 @@
|
||||
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
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" />
|
||||
);
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
|
||||
const navItems = [
|
||||
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
|
||||
@@ -19,7 +19,7 @@ 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' },
|
||||
];
|
||||
@@ -63,7 +63,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
toggleCollapse,
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
toggleCollapse?: () => void;
|
||||
@@ -71,6 +71,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(() => {
|
||||
@@ -99,19 +104,21 @@ export default function Sidebar({
|
||||
return (
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleCollapse}
|
||||
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
|
||||
</button>
|
||||
{!isCollapsed && <PsysonicLogo />}
|
||||
{!isCollapsed && <span className="brand-name">Psysonic</span>}
|
||||
{isCollapsed
|
||||
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
|
||||
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleCollapse}
|
||||
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
||||
data-tooltip-pos="right"
|
||||
>
|
||||
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
||||
</button>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Hauptnavigation">
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
|
||||
{navItems.map(item => (
|
||||
@@ -143,6 +150,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 +191,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>
|
||||
);
|
||||
|
||||
+182
-105
@@ -1,4 +1,5 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ThemeDef {
|
||||
id: string;
|
||||
@@ -10,49 +11,116 @@ interface ThemeDef {
|
||||
|
||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Catppuccin',
|
||||
group: 'Games',
|
||||
themes: [
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
|
||||
{ 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: '#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: 'Nord',
|
||||
group: 'Movies',
|
||||
themes: [
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
|
||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Retro',
|
||||
group: 'Open Source Classics',
|
||||
themes: [
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
|
||||
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
|
||||
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
|
||||
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
|
||||
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Tokyo Night',
|
||||
group: 'Operating Systems',
|
||||
themes: [
|
||||
{ id: 'tokyo-night', label: 'Standard', bg: '#1a1b26', card: '#24283b', accent: '#7aa2f7' },
|
||||
{ id: 'tokyo-night-storm', label: 'Storm', bg: '#24283b', card: '#2f334d', accent: '#7aa2f7' },
|
||||
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
|
||||
{ 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: '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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Psysonic Themes',
|
||||
themes: [
|
||||
{ id: 'classic-winamp', label: 'Classic Winamp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
|
||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#9e9a92' },
|
||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
||||
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
|
||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
|
||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
||||
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mediaplayer',
|
||||
themes: [
|
||||
{ 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: '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: '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: '#100c08', card: '#090c10', accent: '#c41e1e' },
|
||||
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -63,93 +131,102 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ThemePicker({ value, onChange }: Props) {
|
||||
const initialOpen = THEME_GROUPS.find(g => g.themes.some(t => t.id === value))?.group ?? THEME_GROUPS[0].group;
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(initialOpen);
|
||||
|
||||
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{THEME_GROUPS.map(({ group, themes }) => (
|
||||
<div key={group}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
{group}
|
||||
<div className="theme-accordion">
|
||||
{THEME_GROUPS.map(({ group, themes }) => {
|
||||
const isOpen = openGroup === group;
|
||||
const hasActive = themes.some(t => t.id === value);
|
||||
return (
|
||||
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
|
||||
<button className="theme-accordion-header" onClick={() => toggle(group)}>
|
||||
<span>
|
||||
{group}
|
||||
{hasActive && !isOpen && (
|
||||
<span className="theme-accordion-active-dot" />
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={15} className="theme-accordion-chevron" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="theme-accordion-content">
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{themes.map((t) => {
|
||||
const isActive = value === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '46px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
position: 'relative',
|
||||
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||
transition: 'outline-color 0.15s, box-shadow 0.15s',
|
||||
}}>
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: t.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{themes.map((t) => {
|
||||
const isActive = value === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '46px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
position: 'relative',
|
||||
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||
transition: 'outline-color 0.15s, box-shadow 0.15s',
|
||||
}}>
|
||||
{/* main bg */}
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
{/* card tone */}
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
{/* accent bar */}
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: t.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export default function TooltipPortal() {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
|
||||
const tooltipRef = useRef<TooltipState | null>(null);
|
||||
tooltipRef.current = tooltip;
|
||||
|
||||
useEffect(() => {
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
@@ -26,15 +29,19 @@ export default function TooltipPortal() {
|
||||
wrap: target.hasAttribute('data-tooltip-wrap'),
|
||||
});
|
||||
};
|
||||
const onOut = (e: MouseEvent) => {
|
||||
const onOut = () => setTooltip(null);
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!tooltipRef.current) return;
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (target) setTooltip(null);
|
||||
if (!target) setTooltip(null);
|
||||
};
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseout', onOut);
|
||||
document.addEventListener('mousemove', onMove, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseout', onOut);
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import butterchurn from 'butterchurn';
|
||||
import butterchurnPresets from 'butterchurn-presets';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
interface Props {
|
||||
trackId: string;
|
||||
nextPresetTrigger: number;
|
||||
onPresetName: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const presetNamesRef = useRef<string[]>([]);
|
||||
const presetMapRef = useRef<Record<string, unknown>>({});
|
||||
const presetIdxRef = useRef(0);
|
||||
|
||||
// ── Init audio analysis + butterchurn ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Hidden audio element — routed through Web Audio for analysis only.
|
||||
// NOT connected to AudioDestinationNode → completely silent.
|
||||
// The Rust/rodio engine plays the actual audio.
|
||||
const streamUrl = buildStreamUrl(trackId);
|
||||
const audio = new Audio(streamUrl);
|
||||
audio.crossOrigin = 'anonymous';
|
||||
audioRef.current = audio;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
audioCtxRef.current = ctx;
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
// Intentionally no: source.connect(ctx.destination)
|
||||
|
||||
// Size canvas to fill its container
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || window.innerWidth;
|
||||
const h = rect.height || window.innerHeight;
|
||||
canvas.width = w * (window.devicePixelRatio || 1);
|
||||
canvas.height = h * (window.devicePixelRatio || 1);
|
||||
|
||||
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
pixelRatio: window.devicePixelRatio || 1,
|
||||
});
|
||||
vizRef.current = visualizer;
|
||||
visualizer.connectAudio(source);
|
||||
|
||||
// Presets
|
||||
const presets = butterchurnPresets.getPresets();
|
||||
const names = Object.keys(presets);
|
||||
presetNamesRef.current = names;
|
||||
presetMapRef.current = presets;
|
||||
const startIdx = Math.floor(Math.random() * names.length);
|
||||
presetIdxRef.current = startIdx;
|
||||
visualizer.loadPreset(presets[names[startIdx]], 2.0);
|
||||
onPresetName(names[startIdx]);
|
||||
|
||||
// Sync position + play state with main player
|
||||
const { currentTime, isPlaying } = usePlayerStore.getState();
|
||||
audio.currentTime = currentTime;
|
||||
if (isPlaying) audio.play().catch(() => {});
|
||||
|
||||
// Render loop
|
||||
const render = () => {
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
visualizer.render();
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
|
||||
// Keep canvas sized to window
|
||||
const onResize = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = r.width * dpr;
|
||||
canvas.height = r.height * dpr;
|
||||
visualizer.setRendererSize(canvas.width, canvas.height);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Sync play/pause with main player
|
||||
const unsubscribe = usePlayerStore.subscribe(state => {
|
||||
const a = audioRef.current;
|
||||
const c = audioCtxRef.current;
|
||||
if (!a || !c) return;
|
||||
if (state.isPlaying) {
|
||||
c.resume();
|
||||
a.play().catch(() => {});
|
||||
} else {
|
||||
a.pause();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', onResize);
|
||||
unsubscribe();
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
ctx.close();
|
||||
vizRef.current = null;
|
||||
};
|
||||
}, [trackId]); // re-init on track change
|
||||
|
||||
// ── Next preset ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!nextPresetTrigger) return;
|
||||
const viz = vizRef.current;
|
||||
const names = presetNamesRef.current;
|
||||
if (!viz || names.length === 0) return;
|
||||
const next = (presetIdxRef.current + 1) % names.length;
|
||||
presetIdxRef.current = next;
|
||||
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
|
||||
onPresetName(names[next]);
|
||||
}, [nextPresetTrigger]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+1135
-173
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
+95
-32
@@ -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';
|
||||
@@ -29,6 +31,7 @@ export default function AlbumDetail() {
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -41,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;
|
||||
@@ -63,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 }));
|
||||
@@ -179,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) : '';
|
||||
@@ -208,17 +254,34 @@ 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={starredSongs}
|
||||
starredSongs={new Set([
|
||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||
])}
|
||||
onPlaySong={handlePlaySong}
|
||||
onRate={handleRate}
|
||||
onToggleSongStar={toggleSongStar}
|
||||
|
||||
+45
-21
@@ -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
@@ -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,
|
||||
};
|
||||
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) && (
|
||||
|
||||
+52
-20
@@ -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,36 +91,44 @@ 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,
|
||||
};
|
||||
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">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+15
-5
@@ -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; }
|
||||
@@ -43,9 +43,9 @@ export default function Help() {
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q23'), a: t('help.a23') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
{ q: t('help.q29'), a: t('help.a29') },
|
||||
{ q: t('help.q30'), a: t('help.a30') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,8 +64,9 @@ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -85,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'),
|
||||
@@ -101,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>
|
||||
|
||||
+53
-27
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Hero from '../components/Hero';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function Home() {
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -10,20 +11,29 @@ export default function Home() {
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
]).then(([s, n, r, f]) => {
|
||||
getArtists().catch(() => []),
|
||||
]).then(([s, n, r, f, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -35,17 +45,15 @@ export default function Home() {
|
||||
) => {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
// Ensure we don't append duplicates if the API returns them
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) {
|
||||
setter(prev => [...prev, ...newItems]);
|
||||
}
|
||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -58,31 +66,49 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
/>
|
||||
{randomArtists.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{randomArtists.map(a => (
|
||||
<button key={a.id} className="artist-ext-link" onClick={() => navigate(`/artist/${a.id}`)}>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
<button className="artist-ext-link" onClick={() => navigate('/artists')}
|
||||
style={{ opacity: 0.6 }}>
|
||||
{t('home.discoverArtistsMore')} →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
albums={starred}
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
albums={starred}
|
||||
onLoadMore={() => loadMore('starred', starred, setStarred)}
|
||||
moreText={t('home.loadMore')}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
+41
-20
@@ -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>
|
||||
|
||||
+279
-349
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play, Shuffle, Star, Music, Save, FolderOpen, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { Music, Star, ExternalLink, MicVocal } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import {
|
||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||
getPlaylists, getPlaylist, createPlaylist, deletePlaylist,
|
||||
getAlbum, SubsonicPlaylist, SubsonicSong,
|
||||
getAlbum, getArtistInfo,
|
||||
SubsonicSong, SubsonicArtistInfo,
|
||||
} from '../api/subsonic';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
|
||||
@@ -20,13 +19,20 @@ function formatTime(s: number): string {
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDuration(secs: number): string {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function renderStars(rating?: number) {
|
||||
@@ -34,416 +40,340 @@ function renderStars(rating?: number) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '3px', alignItems: 'center' }}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Star
|
||||
key={i}
|
||||
size={14}
|
||||
<Star key={i} size={13}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'rgba(255,255,255,0.4)'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blurred background (crossfade on track change) ───────────────────────────
|
||||
// ─── Animated EQ Bars ─────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
const BAR_COUNT = 24;
|
||||
|
||||
const EQBars = memo(function EQBars({ isPlaying }: { isPlaying: boolean }) {
|
||||
const barsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const heights = useRef<number[]>(Array.from({ length: BAR_COUNT }, () => 0.08));
|
||||
const targets = useRef<number[]>(Array.from({ length: BAR_COUNT }, () => Math.random() * 0.5 + 0.1));
|
||||
const speeds = useRef<number[]>(Array.from({ length: BAR_COUNT }, () => 0.06 + Math.random() * 0.08));
|
||||
const rafRef = useRef<number>();
|
||||
|
||||
const animate = useCallback(() => {
|
||||
heights.current = heights.current.map((h, i) => {
|
||||
const t = targets.current[i];
|
||||
const newH = h + (t - h) * speeds.current[i];
|
||||
if (Math.abs(newH - t) < 0.015) {
|
||||
targets.current[i] = Math.random() * 0.88 + 0.06;
|
||||
speeds.current[i] = 0.05 + Math.random() * 0.10;
|
||||
}
|
||||
return newH;
|
||||
});
|
||||
barsRef.current.forEach((bar, i) => {
|
||||
if (bar) bar.style.height = `${Math.round(heights.current[i] * 100)}%`;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
if (isPlaying) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
// Settle bars to a low resting height
|
||||
heights.current = heights.current.map(() => 0.08);
|
||||
barsRef.current.forEach(bar => {
|
||||
if (bar) bar.style.height = '8%';
|
||||
});
|
||||
}
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [isPlaying, animate]);
|
||||
|
||||
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 className="np-eq-wrap">
|
||||
<div className="np-eq-bars">
|
||||
{Array.from({ length: BAR_COUNT }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="np-eq-bar"
|
||||
ref={el => { barsRef.current[i] = el; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Modals (reused from QueuePanel) ──────────────────────────────────────────
|
||||
// ─── Tag Cloud ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SavePlaylistModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) {
|
||||
interface TagCloudProps {
|
||||
similarArtists: Array<{ id: string; name: string }>;
|
||||
onArtistClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function strHash(s: string): number {
|
||||
let h = 0;
|
||||
for (const c of s) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
|
||||
return h;
|
||||
}
|
||||
|
||||
function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
if (similarArtists.length === 0) return null;
|
||||
|
||||
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
|
||||
const h = strHash(name);
|
||||
const sizePool = [10, 11, 12, 13, 14, 15, 16];
|
||||
const size = sizePool[(h + idx * 7) % sizePool.length];
|
||||
const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400;
|
||||
const pad = size >= 15 ? '5px 10px' : '4px 8px';
|
||||
const opacity = 0.6 + ((h % 5) * 0.08);
|
||||
const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1];
|
||||
const ty = verticals[(h + idx * 4) % verticals.length];
|
||||
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
|
||||
<input
|
||||
type="text" className="live-search-field"
|
||||
placeholder={t('queue.playlistName')} value={name}
|
||||
onChange={e => setName(e.target.value)} autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
|
||||
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
|
||||
<div className="np-tag-cloud">
|
||||
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
|
||||
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '14px' } : undefined}>
|
||||
{row.map((a, i) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="np-tag np-tag-clickable"
|
||||
style={getTagStyle(a.name, rowIdx * 3 + i)}
|
||||
onClick={() => onArtistClick(a.id)}
|
||||
data-tooltip={t('nowPlaying.goToArtist')}
|
||||
>
|
||||
{a.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void; onLoad: (id: string) => void }) {
|
||||
|
||||
// ─── Album Tracklist ──────────────────────────────────────────────────────────
|
||||
|
||||
interface NpTrackListProps {
|
||||
albumTracks: SubsonicSong[];
|
||||
currentTrackId: string;
|
||||
album: string;
|
||||
albumId?: string;
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, album, albumId, onNavigate }: NpTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetch = () => {
|
||||
setLoading(true);
|
||||
getPlaylists().then(d => { setPlaylists(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { fetch(); }, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('queue.deleteConfirm', { name }))) { await deletePlaylist(id); fetch(); }
|
||||
};
|
||||
|
||||
if (albumTracks.length === 0) return null;
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
|
||||
{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: 8, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{playlists.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">{p.name}</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id)} style={{ width: 28, height: 28, background: 'transparent' }}><Play size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} style={{ width: 28, height: 28, background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{album}</em></h3>
|
||||
{albumId && (
|
||||
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
|
||||
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{albumTracks.map(track => {
|
||||
const isActive = track.id === currentTrackId;
|
||||
return (
|
||||
<div key={track.id}
|
||||
className={`np-album-track${isActive ? ' active' : ''}`}
|
||||
onClick={() => albumId && onNavigate(`/album/${albumId}`)}
|
||||
>
|
||||
<span className="np-album-track-num">
|
||||
{isActive
|
||||
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
|
||||
: track.track ?? '—'
|
||||
}
|
||||
</span>
|
||||
<span className="np-album-track-title truncate">{track.title}</span>
|
||||
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _dragFromIdx: number | null = null;
|
||||
|
||||
export default function NowPlaying() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const removeTrack = usePlayerStore(s => s.removeTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
const setQueueVisible = usePlayerStore(s => s.setQueueVisible);
|
||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||
|
||||
// Hide queue panel while on this page, restore on leave
|
||||
useEffect(() => {
|
||||
const wasVisible = usePlayerStore.getState().isQueueVisible;
|
||||
if (wasVisible) setQueueVisible(false);
|
||||
return () => { if (wasVisible) setQueueVisible(true); };
|
||||
}, [setQueueVisible]);
|
||||
|
||||
// Extra song metadata (genre) fetched via getSong
|
||||
// Extra song metadata
|
||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentTrack) { setSongMeta(null); return; }
|
||||
getSong(currentTrack.id).then(setSongMeta);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Favorite state
|
||||
const [starred, setStarred] = useState(false);
|
||||
// Artist info (bio + similar artists)
|
||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
useEffect(() => {
|
||||
setStarred(!!songMeta?.starred);
|
||||
}, [songMeta]);
|
||||
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
|
||||
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
// Album tracks
|
||||
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.albumId) { setAlbumTracks([]); return; }
|
||||
getAlbum(currentTrack.albumId).then(d => setAlbumTracks(d.songs)).catch(() => setAlbumTracks([]));
|
||||
}, [currentTrack?.albumId]);
|
||||
|
||||
// Bio expand toggle
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
useEffect(() => { setBioExpanded(false); }, [currentTrack?.artistId]);
|
||||
|
||||
// Favorite
|
||||
const [starred, setStarred] = useState(false);
|
||||
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
|
||||
const toggleStar = async () => {
|
||||
if (!currentTrack) return;
|
||||
if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); }
|
||||
else { await star(currentTrack.id, 'song'); setStarred(true); }
|
||||
};
|
||||
|
||||
// Cover / background
|
||||
// Cover
|
||||
const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Queue drag-and-drop
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
const draggedIdxRef = useRef<number | null>(null);
|
||||
const dragOverIdxRef = useRef<number | null>(null);
|
||||
const queueListRef = useRef<HTMLDivElement>(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;
|
||||
};
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
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 {}
|
||||
|
||||
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
|
||||
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
|
||||
_dragFromIdx = null;
|
||||
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;
|
||||
}
|
||||
_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);
|
||||
}
|
||||
};
|
||||
|
||||
// Playlist modals
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const similarArtists = artistInfo?.similarArtist ?? [];
|
||||
|
||||
return (
|
||||
<div className="np-page">
|
||||
{/* ── Hero ── */}
|
||||
<div className="np-hero">
|
||||
<NpBg url={resolvedCover ?? ''} />
|
||||
|
||||
<div className="np-hero-content">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{/* Cover + glow */}
|
||||
<div className="np-cover-wrap">
|
||||
{resolvedCover && (
|
||||
<img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden="true" />
|
||||
)}
|
||||
<div className="np-main">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{/* ── Hero Card ── */}
|
||||
<div className="np-hero-card">
|
||||
|
||||
{/* Left: meta info */}
|
||||
<div className="np-hero-left">
|
||||
<div className="np-hero-info">
|
||||
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentTrack.title}</div>
|
||||
<div className="np-artist-album">
|
||||
<span className="np-link"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.artist}</span>
|
||||
<span className="np-sep">·</span>
|
||||
<span className="np-link"
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.album}</span>
|
||||
{currentTrack.year && <><span className="np-sep">·</span><span>{currentTrack.year}</span></>}
|
||||
</div>
|
||||
<div className="np-tech-row">
|
||||
{songMeta?.genre && <span className="np-badge">{songMeta.genre}</span>}
|
||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
<button
|
||||
className="np-star-btn"
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
style={{ color: activeTab === 'lyrics' && isQueueVisible ? 'var(--accent)' : 'white' }}
|
||||
>
|
||||
<MicVocal size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: cover */}
|
||||
<div className="np-hero-cover-wrap">
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt="" className="np-cover" />
|
||||
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>
|
||||
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="np-info">
|
||||
<div className="np-title">{currentTrack.title}</div>
|
||||
{/* Right: tag cloud */}
|
||||
<TagCloud
|
||||
similarArtists={similarArtists}
|
||||
onArtistClick={id => navigate(`/artist/${id}`)}
|
||||
/>
|
||||
|
||||
<div className="np-artist-album">
|
||||
<span
|
||||
className="np-link"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.artist}</span>
|
||||
<span className="np-sep">·</span>
|
||||
<span
|
||||
className="np-link"
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.album}</span>
|
||||
{currentTrack.year && <><span className="np-sep">·</span><span>{currentTrack.year}</span></>}
|
||||
</div>
|
||||
|
||||
<div className="np-tech-row">
|
||||
{songMeta?.genre && <span className="np-badge">{songMeta.genre}</span>}
|
||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
<button
|
||||
onClick={toggleStar}
|
||||
className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="np-empty-state">
|
||||
<Music size={48} style={{ opacity: 0.3 }} />
|
||||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Queue list ── */}
|
||||
<div className="np-queue-section">
|
||||
<div className="np-queue-header">
|
||||
<div>
|
||||
<h2 className="np-queue-title">{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (
|
||||
<div className="np-queue-meta">
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {formatDuration(totalSecs)}
|
||||
{/* ── About the Artist ── */}
|
||||
{artistInfo?.biography && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
{currentTrack.artistId && (
|
||||
<button className="np-card-link" onClick={() => navigate(`/artist/${currentTrack.artistId}`)}>
|
||||
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{artistInfo.largeImageUrl && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-queue-actions">
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.save')} disabled={queue.length === 0}>
|
||||
<Save size={15} />
|
||||
</button>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
<button onClick={clearQueue} className="np-action-btn" data-tooltip={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="np-queue-list"
|
||||
ref={queueListRef}
|
||||
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDrop={onDropQueue}
|
||||
>
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isActive = idx === queueIndex;
|
||||
const isDragging = draggedIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isDragging) dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
||||
else if (isDragOver && draggedIdx !== null) {
|
||||
dragStyle = draggedIdx > idx
|
||||
? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }
|
||||
: { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`np-queue-item ${isActive ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={e => { 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}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="np-queue-num">
|
||||
{isActive
|
||||
? <Play size={12} fill="var(--accent)" color="var(--accent)" />
|
||||
: <span>{idx + 1}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="np-queue-item-info">
|
||||
<div className={`np-queue-item-title truncate ${isActive ? 'np-queue-item-active' : ''}`}>{track.title}</div>
|
||||
<div className="np-queue-item-artist truncate">{track.artist} · {track.album}</div>
|
||||
</div>
|
||||
<div className="np-queue-item-duration">{formatTime(track.duration)}</div>
|
||||
<button
|
||||
className="np-queue-remove"
|
||||
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
|
||||
data-tooltip={t('queue.remove')}
|
||||
><X size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<NpTrackList
|
||||
albumTracks={albumTracks}
|
||||
currentTrackId={currentTrack.id}
|
||||
album={currentTrack.album}
|
||||
albumId={currentTrack.albumId}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="np-empty-state">
|
||||
<Music size={48} style={{ opacity: 0.3 }} />
|
||||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveModalOpen && (
|
||||
<SavePlaylistModal
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={async name => {
|
||||
try { await createPlaylist(name, queue.map(t => t.id)); setSaveModalOpen(false); }
|
||||
catch (e) { console.error(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async id => {
|
||||
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,
|
||||
}));
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
setLoadModalOpen(false);
|
||||
} catch (e) { console.error(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
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 playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists()
|
||||
.then(data => { setPlaylists(data); setLoading(false); })
|
||||
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
|
||||
};
|
||||
|
||||
useEffect(() => { fetchPlaylists(); }, []);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const handlePlay = async (id: string) => {
|
||||
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); }
|
||||
};
|
||||
|
||||
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 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]);
|
||||
|
||||
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)}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-14
@@ -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 ? (
|
||||
|
||||
+182
-135
@@ -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'}`}
|
||||
@@ -210,10 +182,13 @@ export default function RandomMix() {
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Left: Blacklist */}
|
||||
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
<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.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
|
||||
@@ -291,39 +266,52 @@ export default function RandomMix() {
|
||||
</div>
|
||||
|
||||
{/* Right: Genre Mix */}
|
||||
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
||||
<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' }}>
|
||||
{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 }, '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" data-tooltip={song.title}>{song.title}</span></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>
|
||||
<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,49 +383,93 @@ 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 };
|
||||
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" />
|
||||
</button>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist" data-tooltip={song.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">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
@@ -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
@@ -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
|
||||
|
||||
+414
-50
@@ -4,15 +4,21 @@ 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
|
||||
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';
|
||||
import ThemePicker from '../components/ThemePicker';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,7 +26,33 @@ 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'];
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'server' | 'about';
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,14 +112,27 @@ 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();
|
||||
const { state: routeState } = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'playback');
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
@@ -95,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;
|
||||
@@ -207,10 +277,11 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
|
||||
];
|
||||
|
||||
@@ -287,22 +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, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.crossfade')}
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
{t('settings.experimental')}
|
||||
</span>
|
||||
</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"
|
||||
@@ -323,18 +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, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.gapless')}
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
{t('settings.experimental')}
|
||||
</span>
|
||||
</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>
|
||||
@@ -354,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))}
|
||||
@@ -370,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>
|
||||
|
||||
@@ -445,7 +547,7 @@ export default function Settings() {
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} style={{
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
@@ -462,16 +564,6 @@ export default function Settings() {
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Globe size={18} />
|
||||
@@ -487,11 +579,238 @@ 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Type size={18} />
|
||||
<h2>{t('settings.font')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{([
|
||||
{ id: 'inter', label: 'Inter', sample: 'The quick brown fox' },
|
||||
{ id: 'outfit', label: 'Outfit', sample: 'The quick brown fox' },
|
||||
{ id: 'dm-sans', label: 'DM Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'nunito', label: 'Nunito', sample: 'The quick brown fox' },
|
||||
{ id: 'rubik', label: 'Rubik', sample: 'The quick brown fox' },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', sample: 'The quick brown fox' },
|
||||
{ id: 'figtree', label: 'Figtree', sample: 'The quick brown fox' },
|
||||
{ id: 'manrope', label: 'Manrope', sample: 'The quick brown fox' },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'lexend', label: 'Lexend', sample: 'The quick brown fox' },
|
||||
] as { id: FontId; label: string; sample: string }[]).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
background: fontStore.font === f.id ? 'var(--accent-dim)' : 'transparent',
|
||||
border: `1px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--radius-md)', padding: '10px 14px',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: fontStore.font === f.id ? 'var(--accent)' : 'transparent',
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{f.label}</div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{f.sample}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'shortcuts' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.tabShortcuts')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['play-pause', t('settings.shortcutPlayPause')],
|
||||
['next', t('settings.shortcutNext')],
|
||||
['prev', t('settings.shortcutPrev')],
|
||||
['volume-up', t('settings.shortcutVolumeUp')],
|
||||
['volume-down', t('settings.shortcutVolumeDown')],
|
||||
['seek-forward', t('settings.shortcutSeekForward')],
|
||||
['seek-backward', t('settings.shortcutSeekBackward')],
|
||||
['toggle-queue', t('settings.shortcutToggleQueue')],
|
||||
['fullscreen-player', t('settings.shortcutFullscreenPlayer')],
|
||||
['native-fullscreen', t('settings.shortcutNativeFullscreen')],
|
||||
] as [KeyAction, string][]).map(([action, label]) => {
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningFor(null); return; }
|
||||
setListeningFor(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code !== 'Escape') {
|
||||
// unbind any existing action with this key first
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === e.code)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, e.code);
|
||||
}
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatKeyCode(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => kb.setBinding(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.globalShortcutsTitle')}</h2>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}>
|
||||
{t('settings.globalShortcutsNote')}
|
||||
</p>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['play-pause', t('settings.shortcutPlayPause')],
|
||||
['next', t('settings.shortcutNext')],
|
||||
['prev', t('settings.shortcutPrev')],
|
||||
['volume-up', t('settings.shortcutVolumeUp')],
|
||||
['volume-down', t('settings.shortcutVolumeDown')],
|
||||
] as [GlobalAction, string][]).map(([action, label]) => {
|
||||
const bound = gs.shortcuts[action] ?? null;
|
||||
const isListening = listeningForGlobal === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningForGlobal(null); return; }
|
||||
setListeningForGlobal(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const shortcut = buildGlobalShortcut(e);
|
||||
if (shortcut) {
|
||||
gs.setShortcut(action, shortcut);
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => gs.setShortcut(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -653,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>
|
||||
@@ -738,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
|
||||
@@ -782,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('## ['));
|
||||
@@ -805,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}>
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function Statistics() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
|
||||
{track.nowPlaying && (
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
|
||||
+18
-6
@@ -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 }),
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend';
|
||||
|
||||
interface FontState {
|
||||
font: FontId;
|
||||
setFont: (font: FontId) => void;
|
||||
}
|
||||
|
||||
export const useFontStore = create<FontState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
font: 'lexend',
|
||||
setFont: (font) => set({ font }),
|
||||
}),
|
||||
{ name: 'psysonic_font' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { formatKeyCode } from './keybindingsStore';
|
||||
|
||||
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
|
||||
|
||||
const MODIFIER_CODES = [
|
||||
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
|
||||
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
|
||||
];
|
||||
|
||||
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
|
||||
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.includes(e.code)) return null;
|
||||
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
|
||||
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
|
||||
|
||||
const mods: string[] = [];
|
||||
if (e.ctrlKey) mods.push('ctrl');
|
||||
if (e.altKey) mods.push('alt');
|
||||
if (e.shiftKey) mods.push('shift');
|
||||
if (e.metaKey) mods.push('super');
|
||||
|
||||
return [...mods, e.code].join('+');
|
||||
}
|
||||
|
||||
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
|
||||
export function formatGlobalShortcut(shortcut: string): string {
|
||||
return shortcut.split('+').map(part => {
|
||||
if (part === 'ctrl') return 'Ctrl';
|
||||
if (part === 'alt') return 'Alt';
|
||||
if (part === 'shift') return 'Shift';
|
||||
if (part === 'super' || part === 'meta') return 'Super';
|
||||
return formatKeyCode(part);
|
||||
}).join('+');
|
||||
}
|
||||
|
||||
// Module-level guard — prevents double-registration from React StrictMode's
|
||||
// intentional double-invocation of effects in development.
|
||||
let _registerAllCalled = false;
|
||||
|
||||
interface GlobalShortcutsState {
|
||||
shortcuts: Partial<Record<GlobalAction, string>>;
|
||||
setShortcut: (action: GlobalAction, shortcut: string | null) => Promise<void>;
|
||||
registerAll: () => Promise<void>;
|
||||
resetAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useGlobalShortcutsStore = create<GlobalShortcutsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
shortcuts: {},
|
||||
|
||||
setShortcut: async (action, shortcut) => {
|
||||
const prev = get().shortcuts[action];
|
||||
if (prev) {
|
||||
try { await invoke('unregister_global_shortcut', { shortcut: prev }); } catch {}
|
||||
}
|
||||
if (shortcut) {
|
||||
try {
|
||||
await invoke('register_global_shortcut', { shortcut, action });
|
||||
set(s => ({ shortcuts: { ...s.shortcuts, [action]: shortcut } }));
|
||||
} catch (e) {
|
||||
console.warn('[GlobalShortcuts] Failed to register:', shortcut, e);
|
||||
}
|
||||
} else {
|
||||
set(s => {
|
||||
const next = { ...s.shortcuts };
|
||||
delete next[action];
|
||||
return { shortcuts: next };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
registerAll: async () => {
|
||||
if (_registerAllCalled) return;
|
||||
_registerAllCalled = true;
|
||||
const { shortcuts } = get();
|
||||
for (const [action, shortcut] of Object.entries(shortcuts)) {
|
||||
if (shortcut) {
|
||||
try {
|
||||
await invoke('register_global_shortcut', { shortcut, action });
|
||||
} catch (e) {
|
||||
console.warn('[GlobalShortcuts] Failed to re-register:', shortcut, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
resetAll: async () => {
|
||||
const { shortcuts } = get();
|
||||
for (const shortcut of Object.values(shortcuts)) {
|
||||
if (shortcut) {
|
||||
try { await invoke('unregister_global_shortcut', { shortcut }); } catch {}
|
||||
}
|
||||
}
|
||||
set({ shortcuts: {} });
|
||||
},
|
||||
}),
|
||||
{ name: 'psysonic_global_shortcuts' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type KeyAction =
|
||||
| 'play-pause'
|
||||
| 'next'
|
||||
| 'prev'
|
||||
| 'volume-up'
|
||||
| 'volume-down'
|
||||
| 'seek-forward'
|
||||
| 'seek-backward'
|
||||
| 'toggle-queue'
|
||||
| 'fullscreen-player'
|
||||
| 'native-fullscreen';
|
||||
|
||||
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
|
||||
export type Bindings = Record<KeyAction, string | null>;
|
||||
|
||||
export const DEFAULT_BINDINGS: Bindings = {
|
||||
'play-pause': 'Space',
|
||||
'next': null,
|
||||
'prev': null,
|
||||
'volume-up': null,
|
||||
'volume-down': null,
|
||||
'seek-forward': null,
|
||||
'seek-backward': null,
|
||||
'toggle-queue': null,
|
||||
'fullscreen-player': null,
|
||||
'native-fullscreen': 'F11',
|
||||
};
|
||||
|
||||
interface KeybindingsState {
|
||||
bindings: Bindings;
|
||||
setBinding: (action: KeyAction, code: string | null) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
bindings: { ...DEFAULT_BINDINGS },
|
||||
setBinding: (action, code) =>
|
||||
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
|
||||
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
|
||||
}),
|
||||
{ name: 'psysonic_keybindings' }
|
||||
)
|
||||
);
|
||||
|
||||
/** Format an e.code value into a human-readable label. */
|
||||
export function formatKeyCode(code: string): string {
|
||||
if (code === 'Space') return 'Space';
|
||||
if (code === 'ArrowUp') return '↑';
|
||||
if (code === 'ArrowDown') return '↓';
|
||||
if (code === 'ArrowLeft') return '←';
|
||||
if (code === 'ArrowRight') return '→';
|
||||
if (code === 'Escape') return 'Esc';
|
||||
if (code === 'Enter') return 'Enter';
|
||||
if (code === 'Backspace') return '⌫';
|
||||
if (code === 'Tab') return 'Tab';
|
||||
if (code === 'Delete') return 'Del';
|
||||
if (code === 'Home') return 'Home';
|
||||
if (code === 'End') return 'End';
|
||||
if (code === 'PageUp') return 'PgUp';
|
||||
if (code === 'PageDown') return 'PgDn';
|
||||
if (/^F\d+$/.test(code)) return code;
|
||||
if (code.startsWith('Key')) return code.slice(3).toUpperCase();
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
if (code === 'Minus') return '-';
|
||||
if (code === 'Equal') return '=';
|
||||
if (code === 'BracketLeft') return '[';
|
||||
if (code === 'BracketRight') return ']';
|
||||
if (code === 'Semicolon') return ';';
|
||||
if (code === 'Quote') return "'";
|
||||
if (code === 'Backslash') return '\\';
|
||||
if (code === 'Comma') return ',';
|
||||
if (code === 'Period') return '.';
|
||||
if (code === 'Slash') return '/';
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type SidebarTab = 'queue' | 'lyrics';
|
||||
|
||||
interface LyricsState {
|
||||
activeTab: SidebarTab;
|
||||
setTab: (tab: SidebarTab) => void;
|
||||
showLyrics: () => void;
|
||||
showQueue: () => void;
|
||||
}
|
||||
|
||||
export const useLyricsStore = create<LyricsState>()((set) => ({
|
||||
activeTab: 'queue',
|
||||
setTab: (tab) => set({ activeTab: tab }),
|
||||
showLyrics: () => set({ activeTab: 'lyrics' }),
|
||||
showQueue: () => set({ activeTab: 'queue' }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
+283
-63
@@ -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, 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;
|
||||
@@ -23,6 +24,8 @@ export interface Track {
|
||||
replayGainTrackDb?: number;
|
||||
replayGainAlbumDb?: number;
|
||||
replayGainPeak?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -43,6 +46,8 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
replayGainTrackDb: song.replayGain?.trackGain,
|
||||
replayGainAlbumDb: song.replayGain?.albumGain,
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
starred: song.starred,
|
||||
genre: song.genre,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +63,8 @@ interface PlayerState {
|
||||
scrobbled: boolean;
|
||||
lastfmLoved: boolean;
|
||||
lastfmLovedCache: Record<string, boolean>;
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
pause: () => void;
|
||||
@@ -68,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;
|
||||
@@ -118,11 +127,23 @@ 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.
|
||||
let togglePlayLock = false;
|
||||
|
||||
// Timestamp of the last gapless auto-advance (from audio:track_switched).
|
||||
// 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) {
|
||||
@@ -143,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 1–2 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;
|
||||
@@ -151,9 +184,10 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const progress = current_time / dur;
|
||||
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
||||
|
||||
// Scrobble at 50% directly via Last.fm
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
scrobbleSong(track.id, Date.now());
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (scrobblingEnabled && lastfmSessionKey) {
|
||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||
@@ -168,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
|
||||
@@ -199,6 +235,12 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
// If a gapless switch happened recently, this ended event is stale — the
|
||||
// progress task fired it for the OLD source before seeing the chained one.
|
||||
if (Date.now() - lastGaplessSwitchTime < 600) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||
isAudioPaused = false;
|
||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||
@@ -211,6 +253,62 @@ function handleAudioEnded() {
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gapless auto-advance: the Rust engine has already switched to the
|
||||
* next source sample-accurately. We just need to update the UI state without
|
||||
* touching the audio stream (no playTrack() call!).
|
||||
*/
|
||||
function handleAudioTrackSwitched(duration: number) {
|
||||
lastGaplessSwitchTime = Date.now();
|
||||
gaplessPreloadingId = null; // allow preloading for the track after this one
|
||||
isAudioPaused = false;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
let nextTrack: Track | null = null;
|
||||
let newIndex = queueIndex;
|
||||
|
||||
if (repeatMode === 'one' && store.currentTrack) {
|
||||
nextTrack = store.currentTrack;
|
||||
// queueIndex stays the same
|
||||
} else if (nextIdx < queue.length) {
|
||||
nextTrack = queue[nextIdx];
|
||||
newIndex = nextIdx;
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
nextTrack = queue[0];
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
if (!nextTrack) return;
|
||||
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
queueIndex: newIndex,
|
||||
isPlaying: true,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome + Last.fm
|
||||
reportNowPlaying(nextTrack.id);
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (lastfmSessionKey) {
|
||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
|
||||
usePlayerStore.setState(s => ({
|
||||
lastfmLoved: loved,
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
}
|
||||
syncQueueToServer(queue, nextTrack, 0);
|
||||
}
|
||||
|
||||
function handleAudioError(message: string) {
|
||||
console.error('[psysonic] Audio error from backend:', message);
|
||||
isAudioPaused = false;
|
||||
@@ -236,6 +334,7 @@ export function initAudioListeners(): () => void {
|
||||
),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
];
|
||||
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
@@ -255,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()));
|
||||
};
|
||||
}
|
||||
@@ -277,6 +425,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
lastfmLovedCache: {},
|
||||
starredOverrides: {},
|
||||
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
repeatMode: 'off',
|
||||
@@ -351,15 +501,22 @@ 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 });
|
||||
},
|
||||
|
||||
// ── playTrack ────────────────────────────────────────────────────────────
|
||||
playTrack: (track, queue) => {
|
||||
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
||||
// this playTrack call is likely a stale IPC echo — suppress it.
|
||||
if (Date.now() - lastGaplessSwitchTime < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -378,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;
|
||||
@@ -433,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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -508,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);
|
||||
},
|
||||
@@ -532,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);
|
||||
},
|
||||
@@ -578,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',
|
||||
@@ -624,7 +844,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queueIndex: state.queueIndex,
|
||||
currentTime: state.currentTime,
|
||||
lastfmLovedCache: state.lastfmLovedCache,
|
||||
} as Partial<PlayerState>),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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' | 'classic-winamp' | 'poison' | 'nucleo' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
|
||||
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;
|
||||
|
||||
+876
-70
File diff suppressed because it is too large
Load Diff
+226
-30
@@ -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);
|
||||
}
|
||||
@@ -38,7 +43,9 @@
|
||||
flex-direction: column;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 0; /* allow 1fr row to shrink freely */
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
@@ -70,7 +77,7 @@
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
padding: var(--space-1) var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
@@ -176,7 +183,7 @@
|
||||
/* Collapsed Sidebar Styles */
|
||||
.sidebar.collapsed .sidebar-brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-link {
|
||||
@@ -185,21 +192,49 @@
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
position: absolute;
|
||||
top: calc(50% - var(--player-height) / 2);
|
||||
left: calc(var(--sidebar-width) - 11px);
|
||||
transform: translateY(-50%);
|
||||
z-index: 101;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.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 ─── */
|
||||
@@ -266,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;
|
||||
@@ -273,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;
|
||||
}
|
||||
@@ -304,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 ─── */
|
||||
@@ -311,7 +452,7 @@
|
||||
grid-area: player;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-6);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--bg-player);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
@@ -324,9 +465,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
flex: 0 0 320px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-album-art {
|
||||
@@ -385,27 +526,42 @@
|
||||
.player-album-art-wrap.clickable:hover .player-art-expand-hint { opacity: 1; }
|
||||
|
||||
.player-track-meta {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-track-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-track-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Marquee (PlayerBar track name / artist) ── */
|
||||
.marquee-wrap {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.marquee-scroll {
|
||||
display: inline-block;
|
||||
animation: player-marquee 12s linear infinite;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
@keyframes player-marquee {
|
||||
0%, 12% { transform: translateX(0); }
|
||||
78%, 88% { transform: translateX(var(--marquee-amount)); }
|
||||
88.1%, 100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -461,6 +617,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: 32px;
|
||||
margin-right: 32px;
|
||||
}
|
||||
|
||||
.player-waveform-wrap {
|
||||
@@ -474,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 */
|
||||
@@ -485,9 +652,30 @@
|
||||
width: 155px;
|
||||
}
|
||||
|
||||
.player-volume-slider {
|
||||
.player-volume-slider-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-volume-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-volume-pct {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.player-eq-btn {
|
||||
@@ -568,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 {
|
||||
@@ -575,7 +769,7 @@
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--bg-app);
|
||||
border-bottom: 1px solid var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -698,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 {
|
||||
@@ -752,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 {
|
||||
|
||||
+10335
-169
File diff suppressed because it is too large
Load Diff
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
declare module 'butterchurn' {
|
||||
interface Visualizer {
|
||||
connectAudio(audioNode: AudioNode): void;
|
||||
loadPreset(preset: unknown, blendTime: number): void;
|
||||
render(): void;
|
||||
setRendererSize(width: number, height: number): void;
|
||||
}
|
||||
interface CreateOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
pixelRatio?: number;
|
||||
}
|
||||
function createVisualizer(ctx: AudioContext, canvas: HTMLCanvasElement, opts: CreateOptions): Visualizer;
|
||||
export default { createVisualizer };
|
||||
}
|
||||
|
||||
declare module 'butterchurn-presets' {
|
||||
function getPresets(): Record<string, unknown>;
|
||||
export default { getPresets };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { songToTrack } from '../store/playerStore';
|
||||
|
||||
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const steps = 16;
|
||||
const stepMs = durationMs / steps;
|
||||
let step = 0;
|
||||
const id = setInterval(() => {
|
||||
step++;
|
||||
setVolume(Math.max(0, from * (1 - step / steps)));
|
||||
if (step >= steps) {
|
||||
clearInterval(id);
|
||||
resolve();
|
||||
}
|
||||
}, stepMs);
|
||||
});
|
||||
}
|
||||
|
||||
export async function playAlbum(albumId: string): Promise<void> {
|
||||
const albumData = await getAlbum(albumId);
|
||||
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();
|
||||
const { isPlaying, volume } = store;
|
||||
|
||||
if (isPlaying) {
|
||||
await fadeOut(store.setVolume, volume, 700);
|
||||
// Restore volume only in the Zustand store — do NOT call audio_set_volume here,
|
||||
// otherwise the old track glitches back to full volume before playTrack stops it.
|
||||
// playTrack reads state.volume and passes it to audio_play, so the new track
|
||||
// starts at the correct volume without the Rust engine ever hearing a restore.
|
||||
usePlayerStore.setState({ volume });
|
||||
}
|
||||
|
||||
usePlayerStore.getState().playTrack(tracks[0], tracks);
|
||||
}
|
||||
Reference in New Issue
Block a user