mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b67c198227 | |||
| 65a828e3fa | |||
| 6bdd6f3a59 | |||
| d62bffd082 | |||
| ff706104ab | |||
| 0abef4b266 | |||
| 3effad0830 | |||
| 361e9cfdb3 | |||
| 5516d95b52 | |||
| d927ef2082 | |||
| 867c5fbd3e | |||
| e550340565 | |||
| 57b70e6154 | |||
| 0b1ed8cc5a | |||
| c9c68a0e57 | |||
| 9d4997baac | |||
| a99e2e0657 | |||
| 7f85b587b4 | |||
| c8d5e9c028 | |||
| 2ba7845c79 | |||
| 9400a5fb2b |
@@ -100,6 +100,8 @@ jobs:
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
@@ -131,6 +133,9 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
|
||||
- name: upload Linux artifacts
|
||||
|
||||
+11
@@ -7,6 +7,11 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment variables (API keys)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
dist
|
||||
@@ -29,3 +34,9 @@ src-tauri/target/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
+419
@@ -5,8 +5,427 @@ 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.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
|
||||
|
||||
#### Three New Themes
|
||||
- **Poison**: Dark charcoal background (`#1a1a1a`) with phosphor green (`#1bd655`) accent — high-contrast, industrial aesthetic. LCD glow text-shadow on the now-playing track name.
|
||||
- **Nucleo**: Warm brass/cream light theme inspired by vintage hi-fi equipment. Warm white cards, gold/amber accents, brushed-metal bevel buttons, and a warm LCD glow on the player track name. `color-scheme: light`.
|
||||
- **Classic Winamp**: Cool gray-blue dark theme (`#2b2b3a`) channelling the classic Winamp 2.x skin. Yellow primary accent (`#d4cc46`), orange volume slider override (`--volume-accent: #de9b35`), Courier New monospace font with bright-green LCD glow for the track name.
|
||||
|
||||
#### Psychowave Theme — Major Overhaul
|
||||
- Psychowave recoloured from loud neon pink/purple to a refined deep violet palette: background `#161428`, accent `#a06ae0`. All neon colours replaced with muted, tasteful variants. No longer marked as WIP.
|
||||
|
||||
#### ThemePicker Redesign
|
||||
- Themes reorganised into semantic groups: **Catppuccin**, **Nord**, **Retro** (formerly Gruvbox), **Tokyo Night**, and a new **Psysonic Themes** section (Classic Winamp, Poison, Nucleo, Psychowave). The separate *Experimental* group is removed.
|
||||
- "Gruvbox" renamed to **Retro**.
|
||||
|
||||
#### Image Lightbox
|
||||
- Clicking the **album cover** on an Album Detail page or the **artist avatar** on an Artist Detail page opens a full-screen lightbox showing the high-resolution image (up to 2000 px). Click outside or press Escape to close.
|
||||
- Both use a shared `CoverLightbox` component — consistent behaviour across the app.
|
||||
|
||||
#### Queue Toolbar — Complete Redesign
|
||||
- The queue panel now has a **centred icon toolbar** with round buttons (border-radius 50%, solid accent fill when active):
|
||||
- **Shuffle** — Fisher-Yates shuffle, keeps current track at position 0
|
||||
- **Save** — save queue as playlist
|
||||
- **Load** — load a playlist into the queue
|
||||
- **Clear** — remove all tracks from the queue
|
||||
- **Gapless** (∞ icon) — toggle gapless playback on/off
|
||||
- **Crossfade** (≋ icon) — toggle crossfade on/off; when inactive, clicking enables crossfade *and* opens a popover slider
|
||||
- **Crossfade popover**: a small overlay below the Crossfade button with a range slider (1–10 s) to configure the fade duration. Clicking the active Crossfade button disables crossfade and closes the popover. Closes on outside click.
|
||||
- **Queue header**: title enlarged to 16 px/700, track count and total duration shown inline next to the title in accent colour. Close (×) button removed.
|
||||
- **Tech info overlay**: codec and bitrate displayed as a frosted glass badge (`backdrop-filter: blur(4px)`) overlaid on the bottom edge of the cover art image.
|
||||
|
||||
#### French & Dutch Translations
|
||||
- Full UI translation added for **French** (`fr`) and **Dutch** (`nl`) — all namespaces covered.
|
||||
- Language selector in Settings now lists all four languages sorted alphabetically (Dutch, English, French, German).
|
||||
|
||||
#### Help Page — Layout & Content Update
|
||||
- **2-column grid layout** for the accordion — makes better use of horizontal space on widescreen displays.
|
||||
- New Q&A entry: **Crossfade & Gapless** (Playback section) — explains what each feature does, how to enable them, and their experimental status.
|
||||
- Updated entries: Themes (reflects all 21 themes), Languages (4 languages), Scrobbling (direct Last.fm), System browser links, Linux distribution (no AppImage).
|
||||
|
||||
#### Settings — Experimental Labels
|
||||
- Crossfade and Gapless toggles in Settings → Playback now show an **"Experimental"** badge next to their label.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Now Playing dropdown — refresh button**: The refresh icon spin was applied to the entire button, blocking clicks during the animation. Spin state is now separate from the background poll loading state — the button is always clickable, and the icon spins for a minimum of 600 ms for clear visual feedback.
|
||||
- **Crossfade popover positioning**: Popover was overflowing the right edge of the viewport. Now right-aligned relative to the Crossfade button and positioned below it.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.2] - 2026-03-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Last.fm**: Stability improvements for the authentication flow and session handling.
|
||||
- **Settings**: Minor display fixes in the Last.fm profile badge.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.1] - 2026-03-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Build**: TypeScript errors in Settings.tsx and Statistics.tsx that broke the release build.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] - 2026-03-20
|
||||
|
||||
### Added
|
||||
|
||||
#### Last.fm Integration *(Beta)*
|
||||
- **Direct Last.fm scrobbling**: Tracks are scrobbled directly via the Last.fm API at 50% playback — no longer routed through Navidrome. Configure in Settings → Server with your Last.fm username and password.
|
||||
- **Now Playing updates**: Last.fm receives the currently playing track in real time.
|
||||
- **Love / Unlove**: Heart button in the Now Playing page and player bar syncs the loved state with Last.fm instantly.
|
||||
- **Last.fm profile badge** in Settings → Server: shows your scrobble count and member since year once connected.
|
||||
- ⚠️ **This feature is in beta.** Session management and edge cases are still being refined.
|
||||
|
||||
#### Similar Artists
|
||||
- Artist detail pages now show a **Similar Artists** section below Top Tracks, sourced from Last.fm and filtered to artists actually present in your library. Shown as chip buttons — click to navigate directly to that artist's page.
|
||||
- Requires Last.fm to be configured. Hidden when Last.fm is not connected or no library matches are found.
|
||||
|
||||
#### Statistics — Last.fm Stats
|
||||
- New **Last.fm Stats** section on the Statistics page (requires Last.fm): top artists, albums, and tracks with proportional play-count bars.
|
||||
- **Period filter**: switch between Last 7 Days, 1 Month, 3 Months, 6 Months, 12 Months, and Overall.
|
||||
- **Recent Scrobbles**: last 20 scrobbled tracks with relative timestamps and a "Now Playing" badge for the currently active entry.
|
||||
- **Genre Distribution removed**: replaced by the Last.fm stats sections.
|
||||
|
||||
#### Psychowave Theme *(Work in Progress)*
|
||||
- New **Psychowave** theme: a deep purple/violet dark theme inspired by synthwave and retrowave aesthetics.
|
||||
- ⚠️ **Still in active development** — colors and details will continue to be refined in upcoming releases.
|
||||
|
||||
#### Tooltip System — TooltipPortal
|
||||
- All tooltips now use a **React portal** rendered into `document.body` at `z-index: 99999`. Replaces the previous CSS `::after` pseudo-element system.
|
||||
- Fixes tooltip clipping inside `overflow: hidden` containers (player bar, queue panel, EQ).
|
||||
- Fixes black OS-native tooltip boxes that appeared on native `title=` attributes — all converted to `data-tooltip`.
|
||||
- Smart edge detection: tooltip flips position automatically when it would overflow the viewport.
|
||||
|
||||
#### Custom Select Dropdowns
|
||||
- **Theme**, **Language**, and **EQ preset** selectors are now rendered as styled portal dropdowns — no more unstyled native `<select>` boxes.
|
||||
- Supports option groups (EQ: Built-in Presets / Custom Presets), keyboard navigation, and click-outside-to-close.
|
||||
|
||||
### Changed
|
||||
|
||||
#### Fullscreen Player / Now Playing — Background
|
||||
- **Ken Burns animation improved**: background image now has significantly more movement (±8% translate, `inset: -30%`) with a 90-second cycle — more cinematic without being distracting.
|
||||
- **Color orbs removed** from both the Fullscreen Player and the Now Playing page. They caused noticeable GPU load especially on integrated graphics.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Live dropdown (Now Playing)**: Own playback was no longer reported to Navidrome after the Last.fm implementation removed the `reportNowPlaying` call. Both are now called independently on track start.
|
||||
- **Sidebar: Now Playing button position when collapsed**: The button was appearing in the middle of the nav instead of just above the System section. Caused by a leftover `margin-top: auto` on the Statistics link that split the remaining flex space.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-03-19
|
||||
|
||||
> ⚠️ **Wichtiger Hinweis / Important Notice**
|
||||
>
|
||||
> **DE:** Der Bundle-Identifier der App wurde von `dev.psysonic.app` auf `dev.psysonic.player` geändert. **Alle gespeicherten Einstellungen (Server-Profile, Theme, EQ, Sprache usw.) gehen beim Update auf diese Version einmalig verloren** und müssen neu eingetragen werden. Zukünftige Updates sind davon nicht betroffen.
|
||||
>
|
||||
> **EN:** The app's bundle identifier has changed from `dev.psysonic.app` to `dev.psysonic.player`. **All saved settings (server profiles, theme, EQ, language, etc.) will be reset once when updating to this version** and need to be re-entered. Future updates are not affected.
|
||||
|
||||
### Added
|
||||
|
||||
#### Replay Gain
|
||||
|
||||
@@ -33,26 +33,38 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
||||
- **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)
|
||||
- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English + German)
|
||||
- **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()` and `coverArtCacheKey()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. |
|
||||
| `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/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
||||
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50%, server queue sync (debounced 1.5s). 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 (8 themes), applied as `data-theme` on `<html>` |
|
||||
| `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` (100ms), `audio:ended`, `audio:error` events. `MASTER_HEADROOM` (-1 dB) prevents inter-sample clipping at full volume. |
|
||||
| `src/store/themeStore.ts` | Theme selection (60 themes across 8 groups), applied as `data-theme` on `<html>` |
|
||||
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
|
||||
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
|
||||
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
|
||||
| `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on `<html>`. Persisted in `psysonic_font`. |
|
||||
| `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. |
|
||||
| `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. |
|
||||
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
|
||||
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions) |
|
||||
| `src/i18n.ts` | All translations (en + de) inline. Language persisted in `localStorage('psysonic_language')`. |
|
||||
| `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. Shows song count + total duration below title. Items get `.context-active` class while their context menu is open. |
|
||||
| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). **Gapless and Crossfade are mutually exclusive** — enabling one disables the other in both the toolbar and Settings. 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
|
||||
@@ -87,7 +99,7 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
|
||||
### 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`, listens for `audio:progress` / `audio:ended` events, triggers scrobble at 50% via `scrobbleSong`, and debounces server queue sync
|
||||
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`
|
||||
|
||||
@@ -101,18 +113,74 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
|
||||
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
|
||||
|
||||
### Themes
|
||||
8 themes are available, selectable in Settings. `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.
|
||||
60 themes across 8 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
||||
|
||||
| Theme | 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` |
|
||||
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
|
||||
|
||||
| Theme | Group | Style | Accent |
|
||||
|---|---|---|---|
|
||||
| `poison` | Psysonic Themes | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
|
||||
| `nucleo` | Psysonic Themes | warm brass/cream light | Brass `#7a5218` |
|
||||
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
|
||||
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
|
||||
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
|
||||
| `wnamp` | Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
|
||||
| `muma-jukebox` | Mediaplayer | silver/blue light | Blue `#0070a0` |
|
||||
| `winmedplayer` | Mediaplayer | cobalt blue dark | Lime `#45ff00` |
|
||||
| `p-dvd` | Mediaplayer | near-black cinematic | Cyan `#00aaff` |
|
||||
| `spotless` | Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
|
||||
| `dzr0` | Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
|
||||
| `cupertino-beats` | Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
|
||||
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
|
||||
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
|
||||
| `aero-glass` | Operating Systems | Win7 Aero — ice-blue glass sidebar, near-black frosted taskbar player bar, Aero button gradients | Blue `#1878e8` |
|
||||
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
|
||||
| `luna-teal` | Operating Systems | WinXP Luna — warm tan bg, Luna blue task-pane sidebar, XP selection blue hover `#316AC5`, gel buttons | Green `#3c9d29` |
|
||||
| `w11` | Operating Systems | Windows 11 Fluent Design dark — Mica sidebar, clean neutrals, no gradients | Windows Blue `#0078d4` |
|
||||
| `gw1` | Games | Guild Wars 1 — aged Tyrian stone, ornate gold borders, Cinzel serif track name, Searing crimson danger | Gold `#c8960c` |
|
||||
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
|
||||
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
|
||||
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
|
||||
| `tetrastack` | Games | Tetris 8-bit, grid background | Cyan `#00f0f0` |
|
||||
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
|
||||
| `horde` | Games | WoW Horde — Durotar blood-red earth, forge-fire gold glow, iron-plate sidebar | Blood Red `#cc2200` |
|
||||
| `alliance` | Games | WoW Alliance — Stormwind deep navy, cathedral stone, paladin holy-light glow, gold sidebar trim | Royal Blue `#3388cc` |
|
||||
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
|
||||
| `dune` | Movies | Arrakis — warm sand main vs cool sietch-blue `#0e0c1a` sidebar, spice cinnamon `#c8780a` accent, desert horizon gradient, sand-grain scrollbar | Spice `#c8780a` |
|
||||
| `hill-valley-85` | Movies | BTTF — DeLorean time circuit: track name red `#ff2200` Courier New uppercase, artist amber, stainless-steel sidebar lines, fire+lightning radial gradients | Orange `#ff8c00` |
|
||||
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
|
||||
| `middle-earth` | Movies | LOTR — parchment bg, Bag End oak wood-grain sidebar, `ring-inscription-glow` 5s animation on track name, Sammath Naur player bar, seven-stop gold progress bar, Georgia serif | Gold `#d4a820` |
|
||||
| `morpheus` | Movies | Matrix — CRT scanlines on app-shell, vertical code-column sidebar pattern, terminal buttons (monospace, no fill, green glow border-radius 0), 6px scrollbar | Phosphor Green `#00ff41` |
|
||||
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
|
||||
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
|
||||
| `spider-tech` | Movies | Spider-Man — CSS spider web (conic+radial) radiating from sidebar top-left, Into the Spider-Verse halftone dots on app-shell, track name red glow, artist in suit-blue `#4a88ff` | Red `#E62429` |
|
||||
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
|
||||
| `t-800` | Movies | Terminator, Skynet blue | Cyan `#00d4ff` |
|
||||
| `aqua-quartz` | Operating Systems | Mac OS X Aqua, skeuomorphic jelly buttons | Blue `#3876f7` |
|
||||
| `dos` | Operating Systems | MS-DOS — ANSI blue `#0000AA` bg, Courier New everywhere, inverted grey selection, blinking block cursor on track name, 16px DOS scrollbar | Yellow `#FFFF55` |
|
||||
| `unix` | Operating Systems | Unix Shell — pure black, Courier New, `$ ` prompt prefix + blinking `_` cursor on track name, artist in ANSI blue `#5588FF` (ls directory color), thin 6px scrollbar | Green `#22C55E` |
|
||||
| `w3-1` | Operating Systems | Windows 3.1, light silver/teal | Navy `#000080` |
|
||||
| `ice-and-fire` | Series | Game of Thrones — temperature duality: volcanic warm main (`#100c08`) vs cold Castle Black sidebar (`#090c10`), `fire-flicker` 5s irregular animation on track name, dragon gold `#c8880a` artist, red-to-gold content header rule, forge fire glow on player bar | Blood Red `#c41e1e` |
|
||||
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
|
||||
| `heisenberg` | Series | Breaking Bad — periodic table grid on app-shell, hazmat tape diagonal on sidebar, `crystal-pulse` 3s glow animation on track name, lab fluorescent blue top border on player bar | Crystal Blue `#35d4f8` |
|
||||
| `turtle-power` | Series | TMNT, turtle green, brick sidebar | Green `#33cc33` |
|
||||
| `insta` | Social Media | Instagram dark, pink gradient | Pink `#E1306C` |
|
||||
| `readit` | Social Media | Reddit dark, orange-red | OrangeRed `#FF4500` |
|
||||
| `the-book` | Social Media | Facebook light, blue sidebar | Blue `#1877F2` |
|
||||
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
|
||||
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
|
||||
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
|
||||
| `latte` | Open Source Classics | Catppuccin light | Mauve |
|
||||
| `nord` | Open Source Classics | Polar Night dark | Frost `#88c0d0` |
|
||||
| `nord-snowstorm` | Open Source Classics | Snow Storm light | Deep-Blue `#5e81ac` |
|
||||
| `nord-frost` | Open Source Classics | deep ocean blue | Frost `#88c0d0` |
|
||||
| `nord-aurora` | Open Source Classics | Polar Night + aurora | Purple `#b48ead` |
|
||||
| `gruvbox-dark-hard` | Open Source Classics | Gruvbox dark hard | Orange `#fe8019` |
|
||||
| `gruvbox-dark-medium` | Open Source Classics | Gruvbox dark medium | Orange `#fe8019` |
|
||||
| `gruvbox-dark-soft` | Open Source Classics | Gruvbox dark soft | Orange `#fe8019` |
|
||||
| `gruvbox-light-hard` | Open Source Classics | Gruvbox light hard | Orange `#af3a03` |
|
||||
| `gruvbox-light-medium` | Open Source Classics | Gruvbox light medium | Orange `#af3a03` |
|
||||
| `gruvbox-light-soft` | Open Source Classics | Gruvbox light soft | Orange `#af3a03` |
|
||||
|
||||
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
||||
|
||||
@@ -126,7 +194,7 @@ Artist images are intentionally **not loaded** on the Artists overview page (gri
|
||||
`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 German strings live exclusively in `src/i18n.ts` — never hardcode German in `.tsx` files. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
|
||||
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.
|
||||
|
||||
@@ -141,6 +209,8 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
@@ -160,20 +230,23 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- 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`.
|
||||
- **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 atomic sample counter (`CountingSource`) — no wall-clock drift. `audio:ended` fires after ~1 s of consecutive near-end ticks at 100 ms intervals. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert. `MASTER_HEADROOM = 0.891_254` (-1 dB) is applied to all volume calculations to prevent inter-sample clipping from modern 0 dBFS masters + EQ biquad ripple.
|
||||
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` first; if that fails (e.g. FLAC without a SEEKTABLE), the seek silently fails — FLAC files without SEEKTABLE are not seekable. `CountingSource::try_seek` only resets the counter if the inner seek actually succeeded (prevents display desync on failure).
|
||||
- **Gapless + Crossfade mutual exclusion**: Enabling one auto-disables the other, enforced in both Settings (row opacity/pointer-events + onChange) and QueuePanel toolbar (button onClick). Both features running simultaneously caused a glitch: Crossfade moved the Sink (which had Song 2 gapless-chained) to `fading_out_sink`; after Song 1's fade-out, Song 2 played at full volume from the old Sink.
|
||||
- **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). Three CSS-animated color orbs (`--ctp-mauve`, `--ctp-blue`, `--ctp-lavender`) drift behind everything. 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.
|
||||
- **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 / Genres), Recently Played, Most Played, Highest Rated, Genre Chart. Data loaded in parallel via `Promise.allSettled`. No decade distribution (API caps byYear at 200 — all bars show "200+" which is useless).
|
||||
- **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. Default panel width 340 px. Header shows song count + total duration below the queue title.
|
||||
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
|
||||
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
|
||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
|
||||
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow. **Gapless and Crossfade are mutually exclusive** — clicking one disables the other.
|
||||
- **WaveformSeek hover tooltip**: Hovering over the waveform canvas shows a floating time label (`.player-volume-pct`) above the cursor position, reusing the same CSS class as the volume % label. Rendered in a relative `<div>` wrapper; positioned via `left: ${hoverPct * 100}%`. Hidden when no track is loaded or mouse has left the canvas.
|
||||
- **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.
|
||||
@@ -181,5 +254,11 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- **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.
|
||||
- **Tooltip z-index**: `.main-content` has `z-index: 1` so tooltips in the content area render above the queue panel (which has no z-index but appears later in DOM order). Multi-line tooltips: add `data-tooltip-wrap` attribute + use `\n` in the string; CSS rule `[data-tooltip-wrap]::after { white-space: pre-line; max-width: 220px }`.
|
||||
- **Version**: 1.5.0
|
||||
- **Tooltips**: Use `data-tooltip="text"` on any element — never native `title=`. `data-tooltip-pos="top|bottom|left|right"` (default: top). `data-tooltip-wrap` for multi-line. Rendered by `TooltipPortal` in `App.tsx` via `document.body` portal.
|
||||
- **Scrobbling**: At 50% playback, `playerStore` calls `lastfmScrobble()` directly. Navidrome is NOT used for scrobbling. Both `reportNowPlaying` (Navidrome) and `lastfmUpdateNowPlaying` (Last.fm) are called on every `playTrack()` — independently, fire-and-forget.
|
||||
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
|
||||
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
|
||||
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
|
||||
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
|
||||
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
|
||||
- **Version**: 1.16.0
|
||||
|
||||
@@ -22,25 +22,56 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) 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 and German.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
|
||||
- 🎵 **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.
|
||||
- 🔄 **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).
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Native Rust/rodio audio engine (replaces Howler.js)
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback
|
||||
- [x] Waveform seekbar
|
||||
- [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] 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)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] General UI polish & visual refinement
|
||||
- [ ] 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.
|
||||
- **FLAC seeking**: Seeking in FLAC files requires an embedded SEEKTABLE metadata block. Files encoded without one cannot be seeked — clicking the waveform has no effect. Most modern encoders include a SEEKTABLE by default. You can add one retroactively with `metaflac --add-seekpoint=10s *.flac`.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
|
||||
Generated
+2
-61
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "1.8.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -17,8 +17,6 @@
|
||||
"@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",
|
||||
@@ -1667,16 +1665,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 +1712,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 +1774,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 +1831,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 +2189,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",
|
||||
@@ -2496,12 +2443,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
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.6.0",
|
||||
"version": "1.17.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -20,8 +20,6 @@
|
||||
"@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.6.0
|
||||
pkgver=1.17.2
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -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
+613
-52
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.6.0"
|
||||
version = "1.16.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -31,7 +31,9 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
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>
|
||||
+965
-183
File diff suppressed because it is too large
Load Diff
+242
-16
@@ -3,12 +3,23 @@
|
||||
|
||||
mod audio;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
type ShortcutMap = Mutex<HashMap<String, String>>;
|
||||
|
||||
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
|
||||
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
||||
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
@@ -19,12 +30,170 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
#[tauri::command]
|
||||
async fn lastfm_request(
|
||||
params: Vec<[String; 2]>,
|
||||
sign: bool,
|
||||
get: bool,
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
|
||||
map.insert("api_key".into(), api_key.clone());
|
||||
|
||||
if sign {
|
||||
let mut keys: Vec<String> = map.keys().cloned().collect();
|
||||
keys.sort();
|
||||
let sig_str: String = keys.iter()
|
||||
.filter(|k| k.as_str() != "format" && k.as_str() != "callback")
|
||||
.map(|k| format!("{}{}", k, map[k]))
|
||||
.collect::<String>();
|
||||
let sig_input = format!("{}{}", sig_str, api_secret);
|
||||
let digest = md5::compute(sig_input.as_bytes());
|
||||
map.insert("api_sig".into(), format!("{:x}", digest));
|
||||
}
|
||||
|
||||
map.insert("format".into(), "json".into());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = if get {
|
||||
client
|
||||
.get("https://ws.audioscrobbler.com/2.0/")
|
||||
.query(&map)
|
||||
.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.13.0")
|
||||
.send()
|
||||
.await
|
||||
}.map_err(|e| e.to_string())?;
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(err) = json.get("error") {
|
||||
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
|
||||
}
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
|
||||
#[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:?}"))
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -82,27 +251,77 @@ pub fn run() {
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Register media key global shortcuts
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||
{
|
||||
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",
|
||||
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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| {
|
||||
let event_name = match event {
|
||||
MediaControlEvent::Toggle => "media:play-pause",
|
||||
MediaControlEvent::Play => "media:play-pause",
|
||||
MediaControlEvent::Pause => "media:play-pause",
|
||||
MediaControlEvent::Next => "media:next",
|
||||
MediaControlEvent::Previous => "media:prev",
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.emit(event_name, ());
|
||||
let _ = app_handle.emit(event_name, ());
|
||||
}) {
|
||||
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(())
|
||||
@@ -119,6 +338,10 @@ pub fn run() {
|
||||
.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,
|
||||
@@ -128,6 +351,9 @@ pub fn run() {
|
||||
audio::audio_set_eq,
|
||||
audio::audio_preload,
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_chain_preload,
|
||||
lastfm_request,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.6.0",
|
||||
"version": "1.17.2",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -16,14 +16,15 @@
|
||||
"title": "Psysonic",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true,
|
||||
"dragDropEnabled": false
|
||||
"dragDropEnabled": false,
|
||||
"devtools": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -54,6 +55,10 @@
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
|
||||
|
||||
+92
-33
@@ -30,13 +30,18 @@ import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
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();
|
||||
@@ -130,22 +135,24 @@ 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">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="collapse-btn"
|
||||
className="queue-toggle-btn"
|
||||
onClick={toggleQueue}
|
||||
title={t('player.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>
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
@@ -191,6 +198,7 @@ function AppShell() {
|
||||
)}
|
||||
<ContextMenu />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,47 +210,89 @@ function TauriEventBridge() {
|
||||
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)); }],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
];
|
||||
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());
|
||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
||||
const win = getCurrentWindow();
|
||||
const u = await win.onCloseRequested(async (event) => {
|
||||
if (minimizeToTray) {
|
||||
event.preventDefault();
|
||||
await win.hide();
|
||||
} else {
|
||||
await invoke('exit_app');
|
||||
}
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
};
|
||||
|
||||
setup();
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous, minimizeToTray]);
|
||||
|
||||
return null;
|
||||
@@ -250,15 +300,24 @@ function TauriEventBridge() {
|
||||
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const font = useFontStore(s => s.font);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useGlobalShortcutsStore.getState().registerAll();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const API_KEY = '9917fb39049225a13bec225ad6d49054';
|
||||
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
|
||||
|
||||
export function lastfmIsConfigured(): boolean {
|
||||
return Boolean(API_KEY && API_SECRET);
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (typeof e === 'string') return e;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
}
|
||||
|
||||
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
|
||||
const entries = Object.entries(params) as [string, string][];
|
||||
try {
|
||||
const result = await invoke('lastfm_request', {
|
||||
params: entries,
|
||||
sign,
|
||||
get,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
});
|
||||
// Clear session error on any successful authenticated call
|
||||
if (sign) useAuthStore.getState().setLastfmSessionError(false);
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Last.fm error codes 4, 9, 14 = auth/session invalid
|
||||
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
|
||||
useAuthStore.getState().setLastfmSessionError(true);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetToken(): Promise<string> {
|
||||
try {
|
||||
const data = await call({ method: 'auth.getToken' }, false, true);
|
||||
return data.token as string;
|
||||
} catch (e) {
|
||||
throw new Error(errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
export function lastfmAuthUrl(token: string): string {
|
||||
return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`;
|
||||
}
|
||||
|
||||
export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> {
|
||||
try {
|
||||
const data = await call({ method: 'auth.getSession', token }, true, false);
|
||||
return { key: data.session.key as string, name: data.session.name as string };
|
||||
} catch (e) {
|
||||
throw new Error(errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetSimilarArtists(artistName: string): Promise<string[]> {
|
||||
try {
|
||||
const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true);
|
||||
const artists = data?.similarartists?.artist;
|
||||
if (!artists) return [];
|
||||
const arr = Array.isArray(artists) ? artists : [artists];
|
||||
return arr.map((a: any) => a.name as string);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetAllLovedTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
): Promise<Array<{ title: string; artist: string }>> {
|
||||
const results: Array<{ title: string; artist: string }> = [];
|
||||
let page = 1;
|
||||
const limit = 200;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const data = await call({
|
||||
method: 'user.getLovedTracks',
|
||||
user: username,
|
||||
sk: sessionKey,
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
}, false, true);
|
||||
|
||||
const tracks = data?.lovedtracks?.track;
|
||||
if (!tracks) break;
|
||||
const arr = Array.isArray(tracks) ? tracks : [tracks];
|
||||
for (const t of arr) {
|
||||
results.push({ title: t.name, artist: t.artist?.name ?? '' });
|
||||
}
|
||||
|
||||
const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1);
|
||||
if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks
|
||||
page++;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function lastfmGetTrackLoved(
|
||||
title: string,
|
||||
artist: string,
|
||||
sessionKey: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true);
|
||||
return data?.track?.userloved === '1' || data?.track?.userloved === 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmUpdateNowPlaying(
|
||||
track: { title: string; artist: string; album: string; duration: number },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({
|
||||
method: 'track.updateNowPlaying',
|
||||
track: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: String(Math.round(track.duration)),
|
||||
sk: sessionKey,
|
||||
}, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmLoveTrack(
|
||||
track: { title: string; artist: string },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmUnloveTrack(
|
||||
track: { title: string; artist: string },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmUserInfo {
|
||||
playcount: number;
|
||||
registeredAt: number; // unix timestamp
|
||||
}
|
||||
|
||||
export async function lastfmGetUserInfo(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
): Promise<LastfmUserInfo | null> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true);
|
||||
const u = data?.user;
|
||||
if (!u) return null;
|
||||
return {
|
||||
playcount: Number(u.playcount),
|
||||
registeredAt: Number(u.registered?.unixtime ?? 0),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmRecentTrack {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
timestamp: number | null; // null = currently playing
|
||||
nowPlaying: boolean;
|
||||
}
|
||||
|
||||
export async function lastfmGetRecentTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
limit = 20,
|
||||
): Promise<LastfmRecentTrack[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true);
|
||||
const items = data?.recenttracks?.track;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((t: any) => ({
|
||||
name: t.name,
|
||||
artist: t.artist?.['#text'] ?? t.artist?.name ?? '',
|
||||
album: t.album?.['#text'] ?? '',
|
||||
timestamp: t.date?.uts ? Number(t.date.uts) : null,
|
||||
nowPlaying: t['@attr']?.nowplaying === 'true',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month';
|
||||
|
||||
export interface LastfmTopArtist {
|
||||
name: string;
|
||||
playcount: string;
|
||||
}
|
||||
|
||||
export interface LastfmTopAlbum {
|
||||
name: string;
|
||||
playcount: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface LastfmTopTrack {
|
||||
name: string;
|
||||
playcount: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export async function lastfmGetTopArtists(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopArtist[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.topartists?.artist;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetTopAlbums(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopAlbum[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.topalbums?.album;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetTopTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopTrack[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.toptracks?.track;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmScrobble(
|
||||
track: { title: string; artist: string; album: string; duration: number },
|
||||
timestamp: number,
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({
|
||||
method: 'track.scrobble',
|
||||
track: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: String(Math.round(track.duration)),
|
||||
timestamp: String(Math.floor(timestamp / 1000)),
|
||||
sk: sessionKey,
|
||||
}, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+4
-2
@@ -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() {
|
||||
@@ -124,6 +125,7 @@ export interface SubsonicArtistInfo {
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
@@ -165,7 +167,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 ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Play } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
@@ -50,16 +51,16 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -49,6 +50,7 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -97,13 +99,22 @@ export default function AlbumHeader({
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
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 (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightboxOpen && info.coverArt && (
|
||||
<CoverLightbox
|
||||
src={buildCoverArtUrl(info.coverArt, 2000)}
|
||||
alt={`${info.name} Cover`}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
@@ -121,7 +132,14 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
data-tooltip="Vergrößern"
|
||||
aria-label={`${info.name} Cover vergrößern`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
@@ -142,6 +160,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>
|
||||
@@ -174,7 +193,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')}
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function AlbumTrackList({
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
|
||||
coverArt: song.coverArt, track: song.track, year: song.year,
|
||||
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
starred: song.starred, genre: song.genre,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -143,7 +144,7 @@ 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>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
@@ -155,7 +156,7 @@ export default function AlbumTrackList({
|
||||
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>
|
||||
|
||||
@@ -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,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
|
||||
interface Props {
|
||||
@@ -9,17 +10,24 @@ interface Props {
|
||||
|
||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const title =
|
||||
const tooltip =
|
||||
status === 'connected'
|
||||
? t('connection.connected')
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnected')
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div className="connection-indicator" title={title}>
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-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 { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -20,7 +21,7 @@ function sanitizeFilename(name: string): string {
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = 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();
|
||||
@@ -52,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();
|
||||
@@ -65,7 +69,7 @@ export default function ContextMenu() {
|
||||
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,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
}
|
||||
@@ -131,7 +135,7 @@ export default function ContextMenu() {
|
||||
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,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
@@ -147,9 +151,29 @@ 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}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -165,8 +189,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')}
|
||||
@@ -183,8 +212,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>
|
||||
</>
|
||||
);
|
||||
@@ -208,9 +242,29 @@ 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}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CoverLightbox({ src, alt, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
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
|
||||
className="cover-lightbox-img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
group?: string; // group label — shown as non-selectable header when it changes
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: SelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropStyle, setDropStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const maxH = 240;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
|
||||
|
||||
setDropStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!listRef.current?.contains(e.target as Node)
|
||||
) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`custom-select-trigger ${className}`}
|
||||
style={style}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="custom-select-label">{selected?.label ?? value}</span>
|
||||
<ChevronDown size={14} className={`custom-select-chevron ${open ? 'open' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
className="custom-select-dropdown"
|
||||
style={dropStyle}
|
||||
role="listbox"
|
||||
>
|
||||
{options.reduce<React.ReactNode[]>((acc, opt, i) => {
|
||||
const prevGroup = i > 0 ? options[i - 1].group : undefined;
|
||||
if (opt.group && opt.group !== prevGroup) {
|
||||
acc.push(
|
||||
<div key={`group-${opt.group}`} className="custom-select-group-label">
|
||||
{opt.group}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
acc.push(
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`custom-select-option ${opt.value === value ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}`}
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onMouseDown={() => { if (!opt.disabled) { onChange(opt.value); setOpen(false); } }}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
);
|
||||
return acc;
|
||||
}, [])}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Trash2, RotateCcw } from 'lucide-react';
|
||||
import CustomSelect from './CustomSelect';
|
||||
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
@@ -240,31 +241,26 @@ export default function Equalizer() {
|
||||
</label>
|
||||
|
||||
<div className="eq-preset-row">
|
||||
<select
|
||||
className="input eq-preset-select"
|
||||
<CustomSelect
|
||||
className="eq-preset-select"
|
||||
value={selectValue}
|
||||
onChange={e => applyPreset(e.target.value)}
|
||||
>
|
||||
{activePreset === null && <option value="__custom__">{t('settings.eqPresetCustom')}</option>}
|
||||
<optgroup label={t('settings.eqPresetBuiltin')}>
|
||||
{BUILTIN_PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</optgroup>
|
||||
{customPresets.length > 0 && (
|
||||
<optgroup label={t('settings.eqPresetCustomGroup')}>
|
||||
{customPresets.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
onChange={v => applyPreset(v)}
|
||||
options={[
|
||||
...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []),
|
||||
...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })),
|
||||
...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })),
|
||||
]}
|
||||
/>
|
||||
|
||||
{isCustomSaved && (
|
||||
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} title={t('settings.eqDeletePreset')}>
|
||||
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} data-tooltip={t('settings.eqDeletePreset')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} title={t('settings.eqResetBands')}>
|
||||
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} data-tooltip={t('settings.eqResetBands')}>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} title={t('settings.eqSavePreset')}>
|
||||
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} data-tooltip={t('settings.eqSavePreset')}>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
</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,58 +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" />
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" 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">
|
||||
@@ -289,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>
|
||||
|
||||
+16
-2
@@ -5,6 +5,7 @@ import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, get
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
|
||||
@@ -79,6 +80,18 @@ 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) : '';
|
||||
@@ -119,12 +132,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" />
|
||||
@@ -139,7 +153,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
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,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) { }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
|
||||
export default function LastfmIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { lastfmSessionKey, lastfmUsername, lastfmSessionError } = useAuthStore();
|
||||
|
||||
if (!lastfmSessionKey) return null;
|
||||
|
||||
const tooltip = lastfmSessionError
|
||||
? t('connection.lastfmSessionInvalid')
|
||||
: t('connection.lastfmConnected', { user: lastfmUsername });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div
|
||||
className={`connection-led connection-led--${lastfmSessionError ? 'disconnected' : 'connected'}`}
|
||||
/>
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<LastfmIcon size={11} />
|
||||
Last.fm
|
||||
</span>
|
||||
<span className="connection-server">
|
||||
{lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export default function LiveSearch() {
|
||||
...(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 });
|
||||
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, genre: s.genre });
|
||||
setOpen(false); setQuery('');
|
||||
}}))),
|
||||
] : [];
|
||||
@@ -176,7 +176,7 @@ export default function LiveSearch() {
|
||||
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 });
|
||||
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, genre: s.genre });
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function LyricsPane({ currentTrack }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
|
||||
|
||||
const 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 || currentTrack.id === fetchedFor) 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);
|
||||
setFetchedFor(currentTrack.id);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
if (result.syncedLyrics) {
|
||||
const lines = parseLrc(result.syncedLyrics);
|
||||
setSyncedLines(lines.length > 0 ? lines : null);
|
||||
}
|
||||
setPlainLyrics(result.plainLyrics);
|
||||
}).catch(() => {
|
||||
if (!cancelled) { setLoading(false); setNotFound(true); }
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset when track changes
|
||||
useEffect(() => {
|
||||
setFetchedFor(null);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
const activeIdx = hasSynced
|
||||
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
|
||||
: -1;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default function NowPlayingDropdown() {
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchNowPlaying = async () => {
|
||||
@@ -28,6 +29,13 @@ export default function NowPlayingDropdown() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setSpinning(true);
|
||||
fetchNowPlaying().finally(() => {
|
||||
setTimeout(() => setSpinning(false), 600);
|
||||
});
|
||||
};
|
||||
|
||||
// Poll in background so the badge stays current without opening the dropdown
|
||||
useEffect(() => {
|
||||
fetchNowPlaying();
|
||||
@@ -104,11 +112,11 @@ export default function NowPlayingDropdown() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
|
||||
<button
|
||||
onClick={fetchNowPlaying}
|
||||
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
|
||||
style={{ width: '28px', height: '28px', padding: 0 }}
|
||||
onClick={handleRefresh}
|
||||
className="btn btn-ghost"
|
||||
style={{ width: '28px', height: '28px', padding: 0, justifyContent: 'center' }}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<RefreshCw size={14} className={spinning ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,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,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,15 +1,19 @@
|
||||
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
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
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';
|
||||
@@ -22,11 +26,17 @@ 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();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
@@ -37,7 +47,7 @@ export default function PlayerBar() {
|
||||
}, [setVolume]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -69,23 +79,30 @@ 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
|
||||
className="player-btn player-btn-sm player-love-btn"
|
||||
onClick={toggleLastfmLove}
|
||||
aria-label={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Heart size={15} fill={lastfmLoved ? '#e31c23' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
@@ -102,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} />
|
||||
@@ -127,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' : ''}`}
|
||||
@@ -147,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">
|
||||
@@ -174,7 +210,8 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
<Equalizer />
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
</footer>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+179
-64
@@ -1,10 +1,13 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { 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 LyricsPane from './LyricsPane';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -120,6 +123,7 @@ export default function QueuePanel() {
|
||||
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);
|
||||
@@ -130,6 +134,34 @@ export default function QueuePanel() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCrossfadePopover) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (
|
||||
crossfadeBtnRef.current?.contains(e.target as Node) ||
|
||||
crossfadePopoverRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setShowCrossfadePopover(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover]);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
@@ -238,7 +270,7 @@ export default function QueuePanel() {
|
||||
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,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
@@ -255,84 +287,145 @@ export default function QueuePanel() {
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<div>
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
<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 (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
<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}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button onClick={handleLoad} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.loadPlaylist')} data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button onClick={handleClear} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.clear')} data-tooltip={t('queue.clear')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div style={{ width: '1px', height: '14px', background: 'var(--border)', margin: '0 4px' }} />
|
||||
<button onClick={toggleQueue} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.close')} data-tooltip={t('queue.hide')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix ? (
|
||||
`${currentTrack.bitRate} kbps · ${currentTrack.suffix.toUpperCase()}`
|
||||
) : (
|
||||
currentTrack.suffix?.toUpperCase() ?? ''
|
||||
)}
|
||||
</div>
|
||||
{(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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<div className="queue-toolbar-sep" />
|
||||
<button
|
||||
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
|
||||
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<Infinity size={13} />
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (crossfadeEnabled) {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
}}
|
||||
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{showCrossfadePopover && (
|
||||
<div className="crossfade-popover" ref={crossfadePopoverRef}>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(Number(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<div className="queue-list" ref={queueListRef}>
|
||||
@@ -390,6 +483,28 @@ 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
|
||||
@@ -414,7 +529,7 @@ export default function QueuePanel() {
|
||||
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,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
if (tracks.length > 0) {
|
||||
clearQueue();
|
||||
|
||||
+27
-22
@@ -8,10 +8,8 @@ import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
);
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
|
||||
const navItems = [
|
||||
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
|
||||
@@ -38,7 +36,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
|
||||
<div className="update-toast-icon" style={{ marginTop: 'auto' }} data-tooltip={`${t('sidebar.updateAvailable')}: ${latestVersion}`} data-tooltip-pos="bottom">
|
||||
<ArrowUpCircle size={20} />
|
||||
</div>
|
||||
);
|
||||
@@ -63,7 +61,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
toggleCollapse,
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
toggleCollapse?: () => void;
|
||||
@@ -99,18 +97,21 @@ export default function Sidebar({
|
||||
return (
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleCollapse}
|
||||
title={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
||||
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 => (
|
||||
@@ -119,7 +120,8 @@ export default function Sidebar({
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
@@ -130,7 +132,8 @@ export default function Sidebar({
|
||||
<NavLink
|
||||
to="/now-playing"
|
||||
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
||||
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ marginTop: 'auto' }}
|
||||
>
|
||||
<span className="nav-np-icon-wrap">
|
||||
@@ -145,8 +148,8 @@ export default function Sidebar({
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
|
||||
title={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
data-tooltip={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
|
||||
@@ -154,7 +157,8 @@ export default function Sidebar({
|
||||
<NavLink
|
||||
to="/help"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t('sidebar.help') : undefined}
|
||||
data-tooltip={isCollapsed ? t('sidebar.help') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<HelpCircle size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.help')}</span>}
|
||||
@@ -162,7 +166,8 @@ export default function Sidebar({
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t('sidebar.settings') : undefined}
|
||||
data-tooltip={isCollapsed ? t('sidebar.settings') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Settings size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ThemeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Games',
|
||||
themes: [
|
||||
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
|
||||
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
||||
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
|
||||
{ id: 'tetrastack', label: 'TetraStack', bg: '#0a0a0a', card: '#151515', accent: '#00f0f0' },
|
||||
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
||||
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
||||
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Movies',
|
||||
themes: [
|
||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
||||
{ id: '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: '#1f242d', card: '#0a0c10', accent: '#00d4ff' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: 'Operating Systems',
|
||||
themes: [
|
||||
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
|
||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
||||
{ 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: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
||||
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
||||
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
|
||||
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Psysonic Themes',
|
||||
themes: [
|
||||
{ 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: '#00ff00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface TooltipState {
|
||||
text: string;
|
||||
anchorRect: DOMRect;
|
||||
preferBottom: boolean;
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
export default function TooltipPortal() {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
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;
|
||||
if (!target) return;
|
||||
const text = target.getAttribute('data-tooltip');
|
||||
if (!text) return;
|
||||
setTooltip({
|
||||
text,
|
||||
anchorRect: target.getBoundingClientRect(),
|
||||
preferBottom: target.getAttribute('data-tooltip-pos') === 'bottom',
|
||||
wrap: target.hasAttribute('data-tooltip-wrap'),
|
||||
});
|
||||
};
|
||||
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);
|
||||
};
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltip || !boxRef.current) { setStyle({ opacity: 0 }); return; }
|
||||
|
||||
const box = boxRef.current.getBoundingClientRect();
|
||||
const { anchorRect, preferBottom } = tooltip;
|
||||
const GAP = 7;
|
||||
const MARGIN = 8;
|
||||
|
||||
// Decide top or bottom
|
||||
const spaceAbove = anchorRect.top - GAP - box.height;
|
||||
const useBottom = preferBottom || spaceAbove < MARGIN;
|
||||
|
||||
let top = useBottom
|
||||
? anchorRect.bottom + GAP
|
||||
: anchorRect.top - GAP - box.height;
|
||||
|
||||
// Clamp vertically
|
||||
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
|
||||
|
||||
// Centre horizontally, clamp to viewport
|
||||
let left = anchorRect.left + anchorRect.width / 2 - box.width / 2;
|
||||
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
|
||||
|
||||
setStyle({ opacity: 1, top, left });
|
||||
}, [tooltip]);
|
||||
|
||||
if (!tooltip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={boxRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.6)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: tooltip.wrap ? 'pre-line' : 'nowrap',
|
||||
maxWidth: tooltip.wrap ? '220px' : undefined,
|
||||
transition: 'opacity 0.15s ease',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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,8 +64,7 @@ function drawWaveform(
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
|
||||
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
|
||||
const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
|
||||
@@ -97,14 +101,11 @@ function drawWaveform(
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 3 — played (gradient + glow)
|
||||
// Pass 3 — played (accent color + glow)
|
||||
if (progress > 0) {
|
||||
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
|
||||
grad.addColorStop(0, colorBlue);
|
||||
grad.addColorStop(1, colorMauve);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.shadowColor = colorMauve;
|
||||
ctx.fillStyle = colorAccent;
|
||||
ctx.shadowColor = colorAccent;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
@@ -129,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;
|
||||
@@ -179,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>
|
||||
);
|
||||
}
|
||||
|
||||
+1309
-47
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,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);
|
||||
|
||||
@@ -69,6 +70,7 @@ export default function AlbumDetail() {
|
||||
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,
|
||||
starred: s.starred, genre: s.genre,
|
||||
}));
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
@@ -79,6 +81,7 @@ export default function AlbumDetail() {
|
||||
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,
|
||||
starred: s.starred, genre: s.genre,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
};
|
||||
@@ -88,6 +91,7 @@ export default function AlbumDetail() {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
starred: song.starred, genre: song.genre,
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -218,7 +222,10 @@ export default function AlbumDetail() {
|
||||
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}
|
||||
|
||||
+132
-49
@@ -3,10 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -31,13 +34,6 @@ function sanitizeHtml(html: string): string {
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,6 +48,10 @@ export default function ArtistDetail() {
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -61,45 +61,18 @@ export default function ArtistDetail() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
let ownAlbumIds: Set<string>;
|
||||
setFeaturedAlbums([]);
|
||||
getArtist(id).then(artistData => {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
|
||||
]);
|
||||
}).then(([artistInfo, songsData, searchResults]) => {
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
);
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
artist: song.albumArtist ?? '',
|
||||
artistId: '',
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
@@ -107,6 +80,67 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
// "Also Featured On" — loaded in background after main content renders
|
||||
useEffect(() => {
|
||||
if (!id || !artist) return;
|
||||
const ownAlbumIds = new Set(albums.map(a => a.id));
|
||||
setFeaturedLoading(true);
|
||||
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
||||
.catch(() => ({ songs: [], albums: [], artists: [] }))
|
||||
.then(searchResults => {
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
);
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
artist: song.albumArtist ?? '',
|
||||
artistId: '',
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
}, [artist?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [artist?.id]);
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
setOpenedLink(key);
|
||||
@@ -190,16 +224,30 @@ export default function ArtistDetail() {
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && (
|
||||
<CoverLightbox
|
||||
src={buildCoverArtUrl(coverId, 2000)}
|
||||
alt={artist.name}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
@@ -293,7 +341,7 @@ export default function ArtistDetail() {
|
||||
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,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
@@ -325,8 +373,35 @@ export default function ArtistDetail() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Similar Artists (Last.fm) */}
|
||||
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{similarArtists.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
|
||||
@@ -339,14 +414,22 @@ export default function ArtistDetail() {
|
||||
)}
|
||||
|
||||
{/* Also Featured On */}
|
||||
{featuredAlbums.length > 0 && (
|
||||
{(featuredLoading || featuredAlbums.length > 0) && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
{featuredLoading ? (
|
||||
<div className="album-grid-wrap">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap" style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+23
-5
@@ -3,9 +3,10 @@ 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 { ListPlus, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,6 +16,11 @@ export default function Favorites() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
|
||||
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();
|
||||
|
||||
@@ -67,7 +73,7 @@ export default function Favorites() {
|
||||
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,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}}
|
||||
@@ -77,22 +83,24 @@ 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,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
@@ -106,7 +114,7 @@ export default function Favorites() {
|
||||
{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 +126,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>
|
||||
);
|
||||
})}
|
||||
|
||||
+6
-2
@@ -43,8 +43,10 @@ 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') },
|
||||
{ q: t('help.q33'), a: t('help.a33') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -65,6 +67,8 @@ export default function Help() {
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -100,7 +104,7 @@ 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: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.25rem', alignItems: 'start' }}>
|
||||
{sections.map((section, si) => (
|
||||
<section key={si} className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
|
||||
+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')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
+279
-352
@@ -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,419 +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-orb np-orb-1" />
|
||||
<div className="np-orb np-orb-2" />
|
||||
<div className="np-orb np-orb-3" />
|
||||
<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"
|
||||
title={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" title={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" title={t('queue.save')} disabled={queue.length === 0}>
|
||||
<Save size={15} />
|
||||
</button>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" title={t('queue.load')}>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
<button onClick={clearQueue} className="np-action-btn" title={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); }}
|
||||
title={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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function Playlists() {
|
||||
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,
|
||||
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
} catch (e) { console.error('Failed to play playlist', e); }
|
||||
|
||||
+50
-10
@@ -48,6 +48,7 @@ export default function RandomMix() {
|
||||
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);
|
||||
@@ -94,6 +95,7 @@ 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;
|
||||
});
|
||||
|
||||
@@ -210,7 +212,7 @@ export default function RandomMix() {
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Left: Blacklist */}
|
||||
<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.filterPanelTitle')}
|
||||
</div>
|
||||
@@ -291,7 +293,7 @@ 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>
|
||||
@@ -342,16 +344,16 @@ export default function RandomMix() {
|
||||
{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'); }}
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }, '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 } }));
|
||||
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, genre: song.genre } }));
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
|
||||
<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>
|
||||
@@ -391,7 +393,7 @@ export default function RandomMix() {
|
||||
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 };
|
||||
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre };
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
@@ -400,7 +402,7 @@ export default function RandomMix() {
|
||||
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,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
@@ -415,15 +417,53 @@ export default function RandomMix() {
|
||||
</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>
|
||||
|
||||
{(() => {
|
||||
|
||||
@@ -35,12 +35,12 @@ export default function SearchResults() {
|
||||
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,
|
||||
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
|
||||
}, 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,
|
||||
suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
})));
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function SearchResults() {
|
||||
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,
|
||||
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
|
||||
+391
-73
@@ -1,14 +1,21 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
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';
|
||||
@@ -16,7 +23,7 @@ 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';
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -79,13 +86,70 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const fontStore = useFontStore();
|
||||
const kb = useKeybindingsStore();
|
||||
const gs = useGlobalShortcutsStore();
|
||||
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>('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('');
|
||||
const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle');
|
||||
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
|
||||
const [lfmError, setLfmError] = useState<string | null>(null);
|
||||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
||||
lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {});
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
const startLastfmConnect = useCallback(async () => {
|
||||
setLfmError(null);
|
||||
let token: string;
|
||||
try {
|
||||
token = await lastfmGetToken();
|
||||
setLfmPendingToken(token);
|
||||
setLfmState('waiting');
|
||||
await openUrl(lastfmAuthUrl(token));
|
||||
} catch (e: any) {
|
||||
setLfmError(e.message ?? 'Unknown error');
|
||||
setLfmState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll every 2 s until the user authorises or we time out (2 min)
|
||||
const deadline = Date.now() + 120_000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) {
|
||||
setLfmState('error');
|
||||
setLfmError('Timed out — please try again.');
|
||||
setLfmPendingToken(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { key, name } = await lastfmGetSession(token);
|
||||
auth.connectLastfm(key, name);
|
||||
setLfmState('idle');
|
||||
setLfmPendingToken(null);
|
||||
} catch (e: any) {
|
||||
// Error 14 = not yet authorised, keep polling
|
||||
if (e.message?.includes('14')) {
|
||||
setTimeout(poll, 2000);
|
||||
} else {
|
||||
setLfmState('error');
|
||||
setLfmError(e.message ?? 'Unknown error');
|
||||
setLfmPendingToken(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
}, [auth]);
|
||||
|
||||
const testConnection = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
@@ -151,10 +215,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} /> },
|
||||
];
|
||||
|
||||
@@ -231,17 +296,22 @@ export default function Settings() {
|
||||
<div className="divider" />
|
||||
|
||||
{/* Crossfade */}
|
||||
<div className="settings-toggle-row">
|
||||
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.crossfade')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.crossfade')}
|
||||
</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"
|
||||
@@ -262,46 +332,24 @@ export default function Settings() {
|
||||
<div className="divider" />
|
||||
|
||||
{/* Gapless */}
|
||||
<div className="settings-toggle-row">
|
||||
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.gapless')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.gapless')}
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Scrobbling */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
<p style={{ marginBottom: '0.5rem' }}>
|
||||
{t('settings.lfmDesc1')}{' '}
|
||||
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
|
||||
{' '}{t('settings.lfmDesc1b')}
|
||||
</p>
|
||||
<p>{t('settings.lfmDesc2')}</p>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -406,7 +454,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,
|
||||
@@ -425,52 +473,253 @@ export default function Settings() {
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
<Globe size={18} />
|
||||
<h2>{t('settings.language')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={theme.theme}
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
<option value="mocha">Catppuccin Mocha</option>
|
||||
<option value="macchiato">Catppuccin Macchiato</option>
|
||||
<option value="frappe">Catppuccin Frappé</option>
|
||||
<option value="latte">Catppuccin Latte</option>
|
||||
<option value="nord">Nord · Polar Night</option>
|
||||
<option value="nord-snowstorm">Nord · Snowstorm</option>
|
||||
<option value="nord-frost">Nord · Frost</option>
|
||||
<option value="nord-aurora">Nord · Aurora</option>
|
||||
</select>
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Globe size={18} />
|
||||
<h2>{t('settings.language')}</h2>
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">{t('settings.languageEn')}</option>
|
||||
<option value="de">{t('settings.languageDe')}</option>
|
||||
</select>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'server' && (
|
||||
<>
|
||||
@@ -553,6 +802,75 @@ export default function Settings() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<LastfmIcon size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{auth.lastfmSessionKey ? (
|
||||
/* ── Connected state ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
||||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
||||
{lfmUserInfo && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
||||
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
|
||||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => auth.disconnectLastfm()}
|
||||
>
|
||||
{t('settings.lfmDisconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : lfmState === 'waiting' ? (
|
||||
/* ── Waiting for browser auth — auto-polling ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||||
{t('settings.lfmConnecting')}
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Not connected ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('settings.lfmConnectDesc')}
|
||||
</p>
|
||||
{lfmState === 'error' && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
||||
)}
|
||||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
||||
{t('settings.lfmConnect')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Downloads + Tray */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
|
||||
+158
-33
@@ -1,34 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string {
|
||||
const diff = Math.floor(Date.now() / 1000) - timestamp;
|
||||
if (diff < 60) return t('statistics.lfmJustNow');
|
||||
if (diff < 3600) return t('statistics.lfmMinutesAgo', { n: Math.floor(diff / 60) });
|
||||
if (diff < 86400) return t('statistics.lfmHoursAgo', { n: Math.floor(diff / 3600) });
|
||||
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
{ key: '7day', label: 'lfmPeriod7day' },
|
||||
{ key: '1month', label: 'lfmPeriod1month' },
|
||||
{ key: '3month', label: 'lfmPeriod3month' },
|
||||
{ key: '6month', label: 'lfmPeriod6month' },
|
||||
{ key: '12month', label: 'lfmPeriod12month' },
|
||||
{ key: 'overall', label: 'lfmPeriodOverall' },
|
||||
];
|
||||
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation();
|
||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
||||
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
|
||||
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
|
||||
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
|
||||
const [lfmTopTracks, setLfmTopTracks] = useState<LastfmTopTrack[]>([]);
|
||||
const [lfmLoading, setLfmLoading] = useState(false);
|
||||
const [lfmRecentTracks, setLfmRecentTracks] = useState<LastfmRecentTrack[]>([]);
|
||||
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getGenres().catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([rc, fr, hi, g, a]) => {
|
||||
getGenres().catch(() => []),
|
||||
]).then(([rc, fr, hi, a, g]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20));
|
||||
setArtistCount(a.length);
|
||||
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||
setLfmRecentLoading(true);
|
||||
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
|
||||
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
|
||||
.catch(() => setLfmRecentLoading(false));
|
||||
}, [lastfmSessionKey, lastfmUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||
setLfmLoading(true);
|
||||
Promise.all([
|
||||
lastfmGetTopArtists(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
|
||||
lastfmGetTopAlbums(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
|
||||
lastfmGetTopTracks(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
|
||||
]).then(([artists, albums, tracks]) => {
|
||||
setLfmTopArtists(artists);
|
||||
setLfmTopAlbums(albums);
|
||||
setLfmTopTracks(tracks);
|
||||
setLfmLoading(false);
|
||||
}).catch(() => setLfmLoading(false));
|
||||
}, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'frequent' | 'highest',
|
||||
currentList: SubsonicAlbum[],
|
||||
@@ -43,15 +97,10 @@ export default function Statistics() {
|
||||
}
|
||||
};
|
||||
|
||||
const totalSongs = genres.reduce((acc, g) => acc + g.songCount, 0);
|
||||
const totalAlbums = genres.reduce((acc, g) => acc + g.albumCount, 0);
|
||||
const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1);
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums || null },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs || null },
|
||||
{ label: t('statistics.statGenres'), value: genres.length || null },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -90,29 +139,105 @@ export default function Statistics() {
|
||||
moreText={t('statistics.loadMore')}
|
||||
/>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title">{t('statistics.genreDistribution')}</h2>
|
||||
<div className="genre-chart">
|
||||
{genres.map(genre => (
|
||||
<div key={genre.value} className="genre-row">
|
||||
<div className="genre-row-header">
|
||||
<span className="genre-name">{genre.value}</span>
|
||||
<span className="genre-counts">
|
||||
{t('statistics.genreSongs', { count: genre.songCount })}
|
||||
{' · '}
|
||||
{t('statistics.genreAlbums', { count: genre.albumCount })}
|
||||
{/* Last.fm Stats */}
|
||||
{lastfmIsConfigured() && (
|
||||
<section style={{ marginTop: '2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('statistics.lfmTitle')}</h2>
|
||||
{lastfmSessionKey && (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`btn btn-sm ${lfmPeriod === p.key ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => setLfmPeriod(p.key)}
|
||||
style={{ padding: '0.25rem 0.625rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
{t(`statistics.${p.label}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!lastfmSessionKey ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{t('statistics.lfmNotConnected')}</p>
|
||||
) : lfmLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{([
|
||||
{ label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) },
|
||||
{ label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist, playcount: a.playcount })) },
|
||||
{ label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist, playcount: tr.playcount })) },
|
||||
] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => {
|
||||
const max = Math.max(...col.items.map(it => Number(it.playcount)), 1);
|
||||
return (
|
||||
<div key={col.label} style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||
{col.label}
|
||||
</h3>
|
||||
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||
{col.items.map((it, i) => (
|
||||
<li key={`${it.primary}-${i}`}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.625rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.primary}</div>
|
||||
{it.secondary && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.secondary}</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>{Number(it.playcount).toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: '2px', borderRadius: '1px', background: 'var(--glass-border)', overflow: 'hidden', marginLeft: '2.125rem' }}>
|
||||
<div style={{ height: '100%', width: `${(Number(it.playcount) / max) * 100}%`, background: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 0.8 : 0.3, borderRadius: '1px', transition: 'width 0.4s ease' }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Scrobbles */}
|
||||
{lastfmIsConfigured() && lastfmSessionKey && (
|
||||
<section style={{ marginTop: '2rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('statistics.lfmRecentTracks')}</h2>
|
||||
{lfmRecentLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{lfmRecentTracks.map((track, i) => (
|
||||
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
|
||||
{track.nowPlaying && (
|
||||
<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' }}>
|
||||
{track.artist}{track.album ? ` · ${track.album}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||
{track.nowPlaying ? '' : track.timestamp ? relativeTime(track.timestamp, t) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="genre-bar-track">
|
||||
<div
|
||||
className="genre-bar-fill"
|
||||
style={{ width: `${(genre.songCount / maxGenreCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionError: string | null;
|
||||
lastfmSessionError: boolean;
|
||||
|
||||
// Actions
|
||||
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
|
||||
@@ -47,6 +48,9 @@ interface AuthState {
|
||||
setConnecting: (v: boolean) => void;
|
||||
setConnectionError: (e: string | null) => void;
|
||||
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
|
||||
connectLastfm: (sessionKey: string, username: string) => void;
|
||||
disconnectLastfm: () => void;
|
||||
setLastfmSessionError: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
@@ -92,6 +96,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
lastfmSessionError: false,
|
||||
|
||||
addServer: (profile) => {
|
||||
const id = generateId();
|
||||
@@ -126,6 +131,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
setLastfm: (apiKey, apiSecret, sessionKey, username) =>
|
||||
set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }),
|
||||
|
||||
connectLastfm: (sessionKey, username) =>
|
||||
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
|
||||
|
||||
disconnectLastfm: () =>
|
||||
set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }),
|
||||
|
||||
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
|
||||
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
+239
-12
@@ -2,7 +2,8 @@ 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, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
export interface Track {
|
||||
@@ -22,6 +23,8 @@ export interface Track {
|
||||
replayGainTrackDb?: number;
|
||||
replayGainAlbumDb?: number;
|
||||
replayGainPeak?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -42,6 +45,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,6 +60,10 @@ interface PlayerState {
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
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;
|
||||
@@ -83,6 +92,11 @@ interface PlayerState {
|
||||
removeTrack: (index: number) => void;
|
||||
shuffleQueue: () => void;
|
||||
|
||||
toggleLastfmLove: () => void;
|
||||
setLastfmLoved: (v: boolean) => void;
|
||||
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
|
||||
syncLastfmLovedTracks: () => Promise<void>;
|
||||
|
||||
initializeFromServerQueue: () => Promise<void>;
|
||||
|
||||
contextMenu: {
|
||||
@@ -115,6 +129,15 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// 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,29 +166,62 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const progress = current_time / dur;
|
||||
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
||||
|
||||
// Scrobble at 50%
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
scrobbleSong(track.id, Date.now());
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (scrobblingEnabled && lastfmSessionKey) {
|
||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Gapless preload: buffer next track when 30s remain
|
||||
// Pre-buffer / pre-chain next track when 30 s remain.
|
||||
const { gaplessEnabled } = useAuthStore.getState();
|
||||
if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) {
|
||||
if (dur - current_time < 30 && dur - current_time > 0) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id) {
|
||||
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
|
||||
gaplessPreloadingId = nextTrack.id;
|
||||
const nextUrl = buildStreamUrl(nextTrack.id);
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
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
|
||||
// already live — no IPC round-trip at the gap point.
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? nextTrack.replayGainAlbumDb
|
||||
: nextTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -178,6 +234,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;
|
||||
@@ -203,22 +315,63 @@ export function initAudioListeners(): () => void {
|
||||
),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
];
|
||||
|
||||
// Initial sync of crossfade settings to Rust audio engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState();
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
usePlayerStore.getState().syncLastfmLovedTracks();
|
||||
|
||||
// Keep crossfade settings in sync whenever auth store changes.
|
||||
// Initial sync of audio settings to Rust engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
||||
|
||||
// Keep audio settings in sync whenever auth store changes.
|
||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||
invoke('audio_set_crossfade', {
|
||||
enabled: state.crossfadeEnabled,
|
||||
secs: state.crossfadeSecs,
|
||||
}).catch(() => {});
|
||||
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;
|
||||
|
||||
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 when it changes
|
||||
if (isPlaying !== prevIsPlaying) {
|
||||
prevIsPlaying = isPlaying;
|
||||
invoke('mpris_set_playback', {
|
||||
playing: isPlaying,
|
||||
positionSecs: currentTime > 0 ? currentTime : null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubMpris();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -237,6 +390,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTime: 0,
|
||||
volume: 0.8,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
lastfmLovedCache: {},
|
||||
starredOverrides: {},
|
||||
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
repeatMode: 'off',
|
||||
@@ -253,6 +410,55 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
|
||||
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
|
||||
toggleLastfmLove: () => {
|
||||
const { currentTrack, lastfmLoved } = get();
|
||||
const { lastfmSessionKey } = useAuthStore.getState();
|
||||
if (!currentTrack || !lastfmSessionKey) return;
|
||||
const newLoved = !lastfmLoved;
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ lastfmLoved: newLoved, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: newLoved } }));
|
||||
if (newLoved) {
|
||||
lastfmLoveTrack(currentTrack, lastfmSessionKey);
|
||||
} else {
|
||||
lastfmUnloveTrack(currentTrack, lastfmSessionKey);
|
||||
}
|
||||
},
|
||||
|
||||
setLastfmLoved: (v) => {
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ lastfmLoved: v, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v } }));
|
||||
} else {
|
||||
set({ lastfmLoved: v });
|
||||
}
|
||||
},
|
||||
|
||||
syncLastfmLovedTracks: async () => {
|
||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore.getState();
|
||||
if (!lastfmSessionKey || !lastfmUsername) return;
|
||||
const tracks = await lastfmGetAllLovedTracks(lastfmUsername, lastfmSessionKey);
|
||||
const newCache: Record<string, boolean> = {};
|
||||
for (const t of tracks) newCache[`${t.title}::${t.artist}`] = true;
|
||||
// Merge with existing cache (local likes take precedence)
|
||||
set(s => ({ lastfmLovedCache: { ...newCache, ...s.lastfmLovedCache } }));
|
||||
// Update current track's loved state if it's in the new cache
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false;
|
||||
set({ lastfmLoved: loved });
|
||||
}
|
||||
},
|
||||
|
||||
setLastfmLovedForSong: (title, artist, v) => {
|
||||
const cacheKey = `${title}::${artist}`;
|
||||
const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist;
|
||||
set(s => ({
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v },
|
||||
...(isCurrentTrack ? { lastfmLoved: v } : {}),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleRepeat: () => set(state => {
|
||||
const modes = ['off', 'all', 'one'] as const;
|
||||
return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] };
|
||||
@@ -268,8 +474,15 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── 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;
|
||||
gaplessPreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||
|
||||
const state = get();
|
||||
@@ -285,6 +498,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
});
|
||||
|
||||
@@ -310,7 +524,19 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
reportNowPlaying(track.id);
|
||||
const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||
const cacheKey = `${track.title}::${track.artist}`;
|
||||
usePlayerStore.setState(s => ({
|
||||
lastfmLoved: loved,
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
}
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
|
||||
@@ -521,6 +747,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queue: state.queue,
|
||||
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';
|
||||
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';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
+758
-284
File diff suppressed because it is too large
Load Diff
+232
-31
@@ -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 {
|
||||
@@ -62,7 +69,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
background: linear-gradient(135deg, var(--accent), var(--ctp-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -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 ─── */
|
||||
@@ -273,6 +308,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 +340,7 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-6);
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
/* ─── Player Bar ─── */
|
||||
@@ -311,7 +348,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 +361,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 +422,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;
|
||||
@@ -439,7 +491,7 @@
|
||||
.player-btn-primary {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
|
||||
@@ -447,10 +499,11 @@
|
||||
}
|
||||
|
||||
.player-btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Waveform seekbar section */
|
||||
@@ -460,6 +513,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: 32px;
|
||||
margin-right: 32px;
|
||||
}
|
||||
|
||||
.player-waveform-wrap {
|
||||
@@ -484,9 +539,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 {
|
||||
@@ -567,6 +643,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0; /* allow 1fr row to shrink freely */
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
@@ -574,21 +651,141 @@
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.queue-action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.queue-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 var(--space-3);
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.crossfade-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 170px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crossfade-popover-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.crossfade-popover-value {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.crossfade-popover-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crossfade-popover-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.queue-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-subtle);
|
||||
margin: 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-round-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-round-btn:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.queue-round-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.queue-round-btn.active {
|
||||
color: var(--ctp-base);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -602,6 +799,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.queue-current-cover img {
|
||||
@@ -635,15 +833,18 @@
|
||||
}
|
||||
|
||||
.queue-current-tech {
|
||||
font-size: 10px;
|
||||
width: 100%;
|
||||
font-size: 9px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: inline-block;
|
||||
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 {
|
||||
|
||||
+8236
-15
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,39 @@
|
||||
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 tracks = albumData.songs.map(songToTrack);
|
||||
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