mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
10 Commits
app-v1.5.0
..
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b1ed8cc5a | |||
| c9c68a0e57 | |||
| 9d4997baac | |||
| a99e2e0657 | |||
| 7f85b587b4 | |||
| c8d5e9c028 | |||
| 2ba7845c79 | |||
| 9400a5fb2b | |||
| 0e88e8a5cd | |||
| 7de4b97df0 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+175
@@ -5,6 +5,181 @@ 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.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
|
||||
- **Replay Gain support** in the Rust audio engine. Gain and peak values from the Subsonic API are applied per-track at playback time, keeping loudness consistent across your library.
|
||||
- Two modes selectable in Settings → Playback: **Track** (default) and **Album** gain.
|
||||
- Peak limiting applied to prevent clipping: effective gain is capped at `1 / peak`.
|
||||
- Volume slider preserves the gain ratio — `audio_set_volume` multiplies `base_volume × replay_gain_linear`.
|
||||
|
||||
#### Crossfade
|
||||
- **Crossfade between tracks** (0.5 – 12 s, configurable in Settings → Playback).
|
||||
- Old sink is volume-ramped to zero in 30 steps while the new track starts playing; old sink stored in `fading_out_sink` so a subsequent skip cancels the fade-out immediately.
|
||||
- `audio_set_crossfade` Tauri command; synced to Rust on startup and on toggle.
|
||||
|
||||
#### Gapless Preloading *(Experimental — Alpha)*
|
||||
- **Gapless playback**: when ≤ 30 s remain in the current track, the next track's audio is preloaded via `audio_preload` in the background.
|
||||
- `audio_play` checks the preload cache first — if there is a URL match the download is skipped entirely, eliminating the gap between tracks.
|
||||
- The old Sink is kept alive during the new track's download and decode phase; the Sink swap happens atomically after decoding is complete, fixing a subtle **start-of-track audio cut** that occurred regardless of gapless state.
|
||||
- ⚠️ **This feature is experimental and still in active development.** It may not work correctly in all scenarios. Enable it in Settings → Playback at your own discretion.
|
||||
|
||||
#### Settings — Tab Navigation
|
||||
- Settings reorganised into **5 horizontal tabs**: Playback, Library, Appearance, Server, About.
|
||||
- Each tab groups related settings with a matching icon.
|
||||
|
||||
#### Artist Pages — "Also Featured On"
|
||||
- Artist detail pages now show an **"Also Featured On"** section listing albums where the artist appears as a guest or featured performer (but is not the primary album artist).
|
||||
- Implemented via `search3` filtered by `song.artistId`, excluding the artist's own albums.
|
||||
|
||||
#### Download Folder Modal
|
||||
- When no download folder is configured and the user initiates a download (album or track), a **folder picker modal** now appears asking where to save.
|
||||
- Includes a "Remember this folder" checkbox that writes the choice to Settings.
|
||||
- Clear button added in Settings → Server to reset the saved download folder.
|
||||
|
||||
#### Changelog in Settings
|
||||
- The full **Changelog** is now readable inside the app under Settings → About.
|
||||
- Rendered as collapsible version entries; the current version is expanded by default.
|
||||
- Inline Markdown (`**bold**`, `*italic*`, `` `code` ``) is rendered natively.
|
||||
|
||||
#### EQ as Player Bar Popup
|
||||
- The Equalizer is now accessible directly from the **player bar** via a small EQ button, opening as a centred popup overlay — no need to navigate to Settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bundle identifier warning**: changed `identifier` from `dev.psysonic.app` to `dev.psysonic.player` to avoid the macOS `.app` extension conflict warned by Tauri.
|
||||
- **Version mismatch in releases**: `tauri.conf.json` version was out of sync with `package.json` and `Cargo.toml`, causing GitHub Actions to build release artefacts with the wrong version number. All four version sources (`package.json`, `Cargo.toml`, `tauri.conf.json`, `packages/aur/PKGBUILD`) are now kept in sync.
|
||||
|
||||
### Known Issues
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -33,26 +33,32 @@ 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/components/TooltipPortal.tsx` | Global tooltip system. Listens for `mouseover`/`mouseout` on `document`, reads `data-tooltip` / `data-tooltip-pos` / `data-tooltip-wrap` attributes, renders via `createPortal` to `document.body` at `z-index: 99999`. Mounted once in `App.tsx`. Use `data-tooltip` instead of native `title=` everywhere — `title=` produces unstyled OS tooltips. |
|
||||
| `src/components/CustomSelect.tsx` | Styled portal-based dropdown replacing native `<select>`. Accepts `SelectOption[]` with optional `group` and `disabled`. Positioned via `useLayoutEffect`, flips above trigger if near viewport bottom. Use for all select inputs. |
|
||||
| `src/components/LastfmIcon.tsx` | Shared Last.fm SVG logo component. `<LastfmIcon size={16} />`. |
|
||||
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
||||
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
|
||||
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
|
||||
| `src/store/themeStore.ts` | Theme selection (8 themes), applied as `data-theme` on `<html>` |
|
||||
| `src/store/themeStore.ts` | Theme selection (21 themes), applied as `data-theme` on `<html>` |
|
||||
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
|
||||
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions) |
|
||||
| `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 ≋). 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 +93,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 +107,33 @@ 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.
|
||||
21 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
||||
|
||||
| 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 Classic Winamp for orange volume, yellow accent elsewhere).
|
||||
|
||||
| Theme | Group | Style | Accent |
|
||||
|---|---|---|---|
|
||||
| `mocha` | Catppuccin | dark | Mauve |
|
||||
| `macchiato` | Catppuccin | medium-dark | Mauve |
|
||||
| `frappe` | Catppuccin | medium | Mauve |
|
||||
| `latte` | Catppuccin | light | Mauve |
|
||||
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
|
||||
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
|
||||
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
|
||||
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
|
||||
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
|
||||
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
|
||||
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
|
||||
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
|
||||
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
|
||||
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
|
||||
| `tokyo-night` | Tokyo Night | dark blue | Purple `#7aa2f7` |
|
||||
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Purple `#7aa2f7` |
|
||||
| `tokyo-night-light` | Tokyo Night | light | Purple `#7aa2f7` |
|
||||
| `classic-winamp` | Psysonic | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
|
||||
| `poison` | Psysonic | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
|
||||
| `nucleo` | Psysonic | warm brass/cream light | Gold `#c8a860` |
|
||||
| `psychowave` | Psysonic | deep violet synthwave | Purple `#a06ae0` |
|
||||
|
||||
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
||||
|
||||
@@ -126,7 +147,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 +162,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 |
|
||||
|---|---|
|
||||
@@ -165,15 +188,16 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- **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).
|
||||
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
|
||||
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
|
||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned. Default panel width 340 px. Header shows song count + total duration below the queue title.
|
||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
|
||||
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
|
||||
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
|
||||
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
|
||||
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
|
||||
@@ -181,5 +205,9 @@ 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`).
|
||||
- **Version**: 1.8.0
|
||||
|
||||
@@ -22,11 +22,11 @@ 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**: 21 deeply integrated themes — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, and Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) — with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **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** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 💾 **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.
|
||||
@@ -37,9 +37,42 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🔄 **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 *(experimental)*
|
||||
- [x] MilkDrop/Butterchurn visualizer
|
||||
- [x] Waveform seekbar
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with keyword filter & Super Genre mix
|
||||
- [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave)
|
||||
- [x] Internationalization (English, German, French, Dutch)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
|
||||
### 🚧 In Progress
|
||||
- [ ] **Last.fm integration** — stabilising, moving out of beta
|
||||
- [ ] **Gapless playback** — edge case stabilisation
|
||||
- [ ] **Crossfade** — stability improvements
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] FLAC seeking fix
|
||||
- [ ] General UI polish & visual refinement
|
||||
- [ ] 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.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
pkgname=psysonic
|
||||
pkgver=1.4.5
|
||||
pkgver=1.8.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -18,11 +18,11 @@ makedepends=(
|
||||
'rust'
|
||||
'cargo'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
cd "psysonic-$pkgver"
|
||||
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
@@ -32,7 +32,7 @@ build() {
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
cd "psysonic-$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
Generated
+8
-1
@@ -2273,6 +2273,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -3133,9 +3139,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.5.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"md5",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.5.0"
|
||||
version = "1.8.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -31,7 +31,8 @@ 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"
|
||||
|
||||
+691
-113
@@ -111,7 +111,7 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Reset biquad filter state to avoid glitches/clicks after seek.
|
||||
// Reset biquad filter state to avoid glitches after seek.
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
self.current_gains[band] = gain_db;
|
||||
@@ -133,32 +133,300 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Debug logger ─────────────────────────────────────────────────────────────
|
||||
// ─── DynSource — type-erased Source wrapper ───────────────────────────────────
|
||||
//
|
||||
// Allows chaining differently-typed sources (with trimming applied) into a
|
||||
// single concrete type accepted by EqSource<S: Source<Item=f32>>.
|
||||
|
||||
// ─── Engine state (registered as Tauri managed state) ────────────────────────
|
||||
struct DynSource {
|
||||
inner: Box<dyn Source<Item = f32> + Send>,
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
}
|
||||
|
||||
impl DynSource {
|
||||
fn new(src: impl Source<Item = f32> + Send + 'static) -> Self {
|
||||
let channels = src.channels();
|
||||
let sample_rate = src.sample_rate();
|
||||
Self { inner: Box::new(src), channels, sample_rate }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for DynSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> { self.inner.next() }
|
||||
}
|
||||
|
||||
impl Source for DynSource {
|
||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
||||
fn channels(&self) -> u16 { self.channels }
|
||||
fn sample_rate(&self) -> u32 { self.sample_rate }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EqualPowerFadeIn — per-sample sin(t·π/2) fade-in envelope ───────────────
|
||||
//
|
||||
// Applied to every new track:
|
||||
// • Crossfade: fade_dur = crossfade_secs → symmetric equal-power fade-in
|
||||
// • Hard cut: fade_dur = 5 ms → micro-fade eliminates DC-click
|
||||
// • Gapless: fade_dur = 0 → unity gain (no modification)
|
||||
//
|
||||
// gain(t) = sin(t · π/2), t ∈ [0, 1)
|
||||
// At t = 0 gain = 0, at t = 1 gain = 1.
|
||||
// Equal-power property: cos²+sin² = 1 → combined with cos fade-out on Track A
|
||||
// the total perceived loudness stays constant across the crossfade.
|
||||
|
||||
struct EqualPowerFadeIn<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_count: u64,
|
||||
fade_samples: u64,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> EqualPowerFadeIn<S> {
|
||||
fn new(inner: S, fade_dur: Duration) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels() as u64;
|
||||
let fade_samples = if fade_dur.is_zero() {
|
||||
0
|
||||
} else {
|
||||
(fade_dur.as_secs_f64() * sample_rate as f64 * channels as f64) as u64
|
||||
};
|
||||
Self { inner, sample_count: 0, fade_samples }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
let gain = if self.fade_samples == 0 || self.sample_count >= self.fade_samples {
|
||||
1.0
|
||||
} else {
|
||||
let t = self.sample_count as f32 / self.fade_samples as f32;
|
||||
(t * std::f32::consts::FRAC_PI_2).sin()
|
||||
};
|
||||
self.sample_count += 1;
|
||||
Some(sample * gain)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
||||
fn channels(&self) -> u16 { self.inner.channels() }
|
||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Restart the fade envelope after seeking (avoids a mid-song click if
|
||||
// the user seeks to the very beginning while a fade was in progress).
|
||||
self.sample_count = 0;
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
|
||||
//
|
||||
// This is the key mechanism for gapless: the progress task polls `done` to know
|
||||
// exactly when source N has finished inside the Sink, without relying on
|
||||
// wall-clock estimation or the unreliable `Sink::empty()`.
|
||||
|
||||
struct NotifyingSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
done: Arc<AtomicBool>,
|
||||
signalled: bool,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> NotifyingSource<S> {
|
||||
fn new(inner: S, done: Arc<AtomicBool>) -> Self {
|
||||
Self { inner, done, signalled: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for NotifyingSource<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next();
|
||||
if sample.is_none() && !self.signalled {
|
||||
self.signalled = true;
|
||||
self.done.store(true, Ordering::SeqCst);
|
||||
}
|
||||
sample
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
|
||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
||||
fn channels(&self) -> u16 { self.inner.channels() }
|
||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// If we seek backwards the source is no longer exhausted.
|
||||
self.signalled = false;
|
||||
self.done.store(false, Ordering::SeqCst);
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||||
//
|
||||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||||
// samples for LAME) and append end-padding to fill the final frame.
|
||||
// iTunes embeds the exact counts in an ID3v2 COMM frame with description
|
||||
// "iTunSMPB". Format: " 00000000 DELAY PADDING TOTAL ..." (space-separated hex)
|
||||
//
|
||||
// Parsing strategy: scan raw bytes for the ASCII marker, then extract the
|
||||
// first whitespace-separated hex tokens after it.
|
||||
|
||||
struct GaplessInfo {
|
||||
delay_samples: u64,
|
||||
total_valid_samples: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for GaplessInfo {
|
||||
fn default() -> Self {
|
||||
Self { delay_samples: 0, total_valid_samples: None }
|
||||
}
|
||||
}
|
||||
|
||||
fn find_subsequence(data: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
data.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||||
let pos = match find_subsequence(data, b"iTunSMPB") {
|
||||
Some(p) => p,
|
||||
None => return GaplessInfo::default(),
|
||||
};
|
||||
|
||||
// Collect printable ASCII bytes after the tag (skip nulls / control chars)
|
||||
let tail = &data[pos + 8..data.len().min(pos + 8 + 256)];
|
||||
let text: String = tail.iter()
|
||||
.map(|&b| b as char)
|
||||
.filter(|c| c.is_ascii_hexdigit() || *c == ' ')
|
||||
.collect();
|
||||
|
||||
let parts: Vec<&str> = text.split_whitespace().collect();
|
||||
// parts[0] = "00000000", parts[1] = delay, parts[2] = padding, parts[3] = total
|
||||
if parts.len() < 3 {
|
||||
return GaplessInfo::default();
|
||||
}
|
||||
let delay = u64::from_str_radix(parts.get(1).unwrap_or(&"0"), 16).unwrap_or(0);
|
||||
let padding = u64::from_str_radix(parts.get(2).unwrap_or(&"0"), 16).unwrap_or(0);
|
||||
let total_raw = parts.get(3).and_then(|s| u64::from_str_radix(s, 16).ok());
|
||||
|
||||
let total_valid = total_raw.map(|t| t).filter(|&t| t > 0).or_else(|| {
|
||||
// Derive from delay + padding if total not available:
|
||||
// Not possible without knowing total encoded samples, so just use None.
|
||||
let _ = padding;
|
||||
None
|
||||
});
|
||||
|
||||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
||||
}
|
||||
|
||||
/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify.
|
||||
///
|
||||
/// `fade_in_dur`:
|
||||
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
|
||||
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
|
||||
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
|
||||
fn build_source(
|
||||
data: Vec<u8>,
|
||||
duration_hint: f64,
|
||||
eq_gains: Arc<[AtomicU32; 10]>,
|
||||
eq_enabled: Arc<AtomicBool>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
) -> Result<(NotifyingSource<EqualPowerFadeIn<EqSource<DynSource>>>, f64), String> {
|
||||
let gapless = parse_gapless_info(&data);
|
||||
|
||||
let cursor = Cursor::new(data);
|
||||
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
|
||||
let sample_rate = decoder.sample_rate();
|
||||
|
||||
// Determine effective duration.
|
||||
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
|
||||
let effective_dur = if duration_hint > 1.0 {
|
||||
duration_hint
|
||||
} else {
|
||||
decoder.total_duration()
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(duration_hint)
|
||||
};
|
||||
|
||||
// Apply encoder-delay trim and optional end-padding trim.
|
||||
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
||||
let delay_dur = Duration::from_secs_f64(
|
||||
gapless.delay_samples as f64 / sample_rate as f64
|
||||
);
|
||||
let base = decoder.convert_samples::<f32>().skip_duration(delay_dur);
|
||||
|
||||
if let Some(total) = gapless.total_valid_samples {
|
||||
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
|
||||
DynSource::new(base.take_duration(valid_dur))
|
||||
} else {
|
||||
DynSource::new(base)
|
||||
}
|
||||
} else {
|
||||
DynSource::new(decoder.convert_samples::<f32>())
|
||||
};
|
||||
|
||||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let notifying = NotifyingSource::new(fade_in, done_flag);
|
||||
|
||||
Ok((notifying, effective_dur))
|
||||
}
|
||||
|
||||
// ─── Engine state ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) struct PreloadedTrack {
|
||||
url: String,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Info about the track that has been appended (chained) to the current Sink
|
||||
/// but whose source has not yet started playing (gapless mode only).
|
||||
struct ChainedInfo {
|
||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||
url: String,
|
||||
duration_secs: f64,
|
||||
replay_gain_linear: f32,
|
||||
base_volume: f32,
|
||||
/// Set by NotifyingSource when this chained track's source is exhausted.
|
||||
source_done: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<rodio::OutputStreamHandle>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
/// Monotonically incremented on each audio_play / audio_stop call.
|
||||
/// The background progress task captures its own `gen` at creation and
|
||||
/// bails out if this counter has moved on, preventing stale events.
|
||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
pub http_client: reqwest::Client,
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||
/// When true, audio_play chains sources to the existing Sink instead of
|
||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||
pub gapless_enabled: Arc<AtomicBool>,
|
||||
/// Info about the next-up chained track (gapless mode).
|
||||
/// The progress task reads this when `current_source_done` fires.
|
||||
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
/// The active rodio Sink. `None` when stopped.
|
||||
pub sink: Option<Sink>,
|
||||
pub duration_secs: f64,
|
||||
/// Position (seconds) that we seeked/resumed from.
|
||||
pub seek_offset: f64,
|
||||
/// Instant when we started counting from seek_offset (None when paused/stopped).
|
||||
pub play_started: Option<Instant>,
|
||||
/// Set when paused; holds the position at pause time.
|
||||
pub paused_at: Option<f64>,
|
||||
pub replay_gain_linear: f32,
|
||||
pub base_volume: f32,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
@@ -175,9 +443,6 @@ impl AudioCurrent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise the audio engine. Spawns a dedicated thread that holds the
|
||||
/// `OutputStream` alive for the lifetime of the process (parking prevents
|
||||
/// the thread — and thus the stream — from being dropped).
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
|
||||
@@ -186,14 +451,9 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
.spawn(move || match rodio::OutputStream::try_default() {
|
||||
Ok((_stream, handle)) => {
|
||||
tx.send(handle).ok();
|
||||
// Park forever — `_stream` must stay alive for audio to work.
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[psysonic] audio output error: {e}");
|
||||
loop { std::thread::park(); }
|
||||
}
|
||||
Err(e) => { eprintln!("[psysonic] audio output error: {e}"); }
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
@@ -207,6 +467,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: reqwest::Client::builder()
|
||||
@@ -215,6 +477,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
.unwrap_or_default(),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -228,128 +496,370 @@ pub struct ProgressPayload {
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch track bytes from the preload cache or via HTTP.
|
||||
async fn fetch_data(
|
||||
url: &str,
|
||||
state: &AudioEngine,
|
||||
gen: u64,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
// Check preload cache first.
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
preloaded.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(data) = cached {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None); // superseded
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
fn compute_gain(
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
volume: f32,
|
||||
) -> (f32, f32) {
|
||||
let gain_linear = replay_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0))
|
||||
.unwrap_or(1.0);
|
||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
|
||||
(gain_linear, effective)
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Download and play the given URL. Replaces any currently playing track.
|
||||
/// Emits `audio:playing` (with duration as f64) once playback starts,
|
||||
/// then `audio:progress` every 500 ms, and `audio:ended` when done.
|
||||
#[tauri::command]
|
||||
pub async fn audio_play(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Claim this generation — any in-flight progress task with the old gen will exit.
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// Stop existing playback immediately.
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() {
|
||||
sink.stop();
|
||||
}
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
cur.duration_secs = duration_hint;
|
||||
}
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────────────────
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
|
||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
||||
// advance. The source is live in the queue — just return and let the
|
||||
// progress task handle the state transition when the previous source ends.
|
||||
if gapless {
|
||||
let already_chained = state.chained_info.lock().unwrap()
|
||||
.as_ref()
|
||||
.map(|c| c.url == url)
|
||||
.unwrap_or(false);
|
||||
if already_chained {
|
||||
return Ok(());
|
||||
}
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
// ── Standard (new-sink) path ─────────────────────────────────────────────
|
||||
// Used for: manual skip, gapless OFF, first play, or gapless when the
|
||||
// proactive chain was not set up in time.
|
||||
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel any pending chain (manual skip while gapless chain was set up).
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
|
||||
// Stop fading-out sink from previous crossfade.
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
// Fetch bytes (may use preload cache).
|
||||
let data = match fetch_data(&url, &state, gen, &app).await? {
|
||||
Some(d) => d,
|
||||
None => return Ok(()), // superseded while downloading
|
||||
};
|
||||
|
||||
// Bail if superseded while downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ────────────────────────────────────────────────────────────────
|
||||
let data: Vec<u8> = bytes.into();
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
|
||||
// Trust the Subsonic API duration_hint as the primary source.
|
||||
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
|
||||
// return a single-frame or header duration that is far too short).
|
||||
let decoder_duration = {
|
||||
let cursor = Cursor::new(data.clone());
|
||||
Decoder::new(cursor)
|
||||
.ok()
|
||||
.and_then(|d| d.total_duration())
|
||||
.map(|d| d.as_secs_f64())
|
||||
};
|
||||
let duration_secs = if duration_hint > 1.0 {
|
||||
duration_hint
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
// have consumed ~500–800ms from Track A's tail — so its true remaining time
|
||||
// is always less than crossfade_secs_val. Using the measured remaining time
|
||||
// for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and
|
||||
// guarantees Track A reaches 0 exactly when its source exhausts.
|
||||
let actual_fade_secs: f32 = if crossfade_enabled {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let remaining = (cur.duration_secs - cur.position()) as f32;
|
||||
remaining.clamp(0.1, crossfade_secs_val)
|
||||
} else {
|
||||
decoder_duration.unwrap_or(duration_hint)
|
||||
0.0
|
||||
};
|
||||
|
||||
let cursor = Cursor::new(data);
|
||||
let decoder = Decoder::new(cursor).map_err(|e| {
|
||||
app.emit("audio:error", e.to_string()).ok();
|
||||
e.to_string()
|
||||
})?;
|
||||
// Fade-in duration for Track B:
|
||||
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
|
||||
// hard cut → 5 ms micro-fade to suppress DC-offset click
|
||||
let fade_in_dur = if crossfade_enabled {
|
||||
Duration::from_secs_f32(actual_fade_secs)
|
||||
} else {
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Final generation check before committing.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Create sink and start playback ────────────────────────────────────────
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
let eq_source = EqSource::new(
|
||||
decoder.convert_samples::<f32>(),
|
||||
// Build source: decode → trim → EQ → fade-in → notify.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let (source, duration_secs) = build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
);
|
||||
sink.append(eq_source);
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||
|
||||
{
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(effective_volume);
|
||||
|
||||
// Gapless OFF: prepend a short silence so tracks are clearly separated.
|
||||
// Only when this is an auto-advance (near end), not on manual skip.
|
||||
if !gapless {
|
||||
let cur_pos = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.position()
|
||||
};
|
||||
let cur_dur = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.duration_secs
|
||||
};
|
||||
let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
|
||||
if is_auto_advance {
|
||||
let silence = rodio::source::Zero::<f32>::new(
|
||||
source.channels(),
|
||||
source.sample_rate(),
|
||||
).take_duration(Duration::from_millis(500));
|
||||
sink.append(silence);
|
||||
}
|
||||
}
|
||||
|
||||
sink.append(source);
|
||||
|
||||
// Atomically swap sinks.
|
||||
let (old_sink, old_vol) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
|
||||
let old = cur.sink.take();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
(old, old_vol)
|
||||
};
|
||||
|
||||
// Handle old sink: equal-power crossfade or immediate stop.
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
tokio::spawn(async move {
|
||||
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
|
||||
// Duration = actual_fade_secs (Track A's measured remaining time),
|
||||
// so the fade reaches exactly 0 when the source is exhausted.
|
||||
const STEP_MS: u64 = 10;
|
||||
let total_steps = ((actual_fade_secs * 1000.0) / STEP_MS as f32).round() as u32;
|
||||
for i in (0..=total_steps).rev() {
|
||||
let alive = {
|
||||
let fo = fo_arc.lock().unwrap();
|
||||
match fo.as_ref() {
|
||||
Some(s) => {
|
||||
// Equal-power cos curve: gain_a = cos(t · π/2)
|
||||
// t goes 1→0 as i goes total_steps→0
|
||||
let t = i as f32 / total_steps as f32;
|
||||
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
|
||||
s.set_volume(old_vol * gain);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
// MutexGuard dropped here before the await
|
||||
};
|
||||
if !alive { return; }
|
||||
tokio::time::sleep(Duration::from_millis(STEP_MS)).await;
|
||||
}
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
|
||||
// ── Progress + ended detection ────────────────────────────────────────────
|
||||
// We do NOT use `sink.empty()` because in rodio 0.19 the source moves from
|
||||
// the pending queue to the active state almost immediately after `append()`,
|
||||
// making `empty()` return `true` within milliseconds even for long tracks.
|
||||
//
|
||||
// Instead we use the wall-clock position (seek_offset + elapsed).
|
||||
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it
|
||||
// reaches the end it stays there. We fire `audio:ended` after two
|
||||
// consecutive ticks where position >= duration - 1.0 s, which:
|
||||
// • avoids false positives from seeking very close to the end
|
||||
// • fires roughly 0.5–1 s before the last sample, giving the frontend
|
||||
// enough time to queue the next download.
|
||||
let gen_counter = state.generation.clone();
|
||||
let current_arc = state.current.clone();
|
||||
let app_clone = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.current.clone(),
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Proactively appends the next track to the current Sink ~30 s before the
|
||||
/// current track ends. Called from JS at the same trigger point as preload.
|
||||
///
|
||||
/// Because this runs well before the track boundary, the IPC round-trip is
|
||||
/// irrelevant — by the time the current track actually ends, the next source
|
||||
/// is already live in the Sink queue and rodio transitions at sample accuracy.
|
||||
///
|
||||
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
|
||||
/// immediately without touching the Sink (pure no-op on the audio path).
|
||||
#[tauri::command]
|
||||
pub async fn audio_chain_preload(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Idempotent: already chained this URL → nothing to do.
|
||||
{
|
||||
let chained = state.chained_info.lock().unwrap();
|
||||
if chained.as_ref().map(|c| c.url == url).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Gapless must be enabled and a sink must exist.
|
||||
if !state.gapless_enabled.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let snapshot_gen = state.generation.load(Ordering::SeqCst);
|
||||
|
||||
// Fetch bytes — use preload cache if available, otherwise HTTP.
|
||||
let data: Vec<u8> = {
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
preloaded.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(d) = cached {
|
||||
d
|
||||
} else {
|
||||
let resp = state.http_client.get(&url).send().await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(()); // silently fail — audio_play will retry
|
||||
}
|
||||
resp.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
}
|
||||
};
|
||||
|
||||
// Bail if the user skipped to a different track while we were downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
|
||||
let done_next = Arc::new(AtomicBool::new(false));
|
||||
let (source, duration_secs) = build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
done_next.clone(),
|
||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
// Final gen check — reject if a manual skip happened during decode.
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
{
|
||||
let cur = state.current.lock().unwrap();
|
||||
match &cur.sink {
|
||||
Some(sink) => {
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(source);
|
||||
}
|
||||
None => return Ok(()), // playback stopped — bail
|
||||
}
|
||||
}
|
||||
|
||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||
url,
|
||||
duration_secs,
|
||||
replay_gain_linear: gain_linear,
|
||||
base_volume: volume.clamp(0.0, 1.0),
|
||||
source_done: done_next,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawns the per-generation progress + ended-detection task.
|
||||
///
|
||||
/// The task owns a local `done: Arc<AtomicBool>` reference that starts as
|
||||
/// the current track's done flag. When the progress task detects that the
|
||||
/// done flag is set AND `chained_info` has data, it swaps `done` to the
|
||||
/// chained source's flag and transitions state — all without creating a new
|
||||
/// task or changing the generation counter.
|
||||
fn spawn_progress_task(
|
||||
gen: u64,
|
||||
gen_counter: Arc<AtomicU64>,
|
||||
current_arc: Arc<Mutex<AudioCurrent>>,
|
||||
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled_arc: Arc<AtomicBool>,
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
// Local done-flag reference; swapped on gapless transition.
|
||||
let mut current_done = initial_done;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
@@ -358,29 +868,55 @@ pub async fn audio_play(
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Gapless transition detection ─────────────────────────────────
|
||||
// If the current source is exhausted AND we have a chained track
|
||||
// ready, transition seamlessly: swap tracking state, emit
|
||||
// audio:playing for the new track, and continue the loop.
|
||||
if current_done.load(Ordering::SeqCst) {
|
||||
let chained = chained_arc.lock().unwrap().take();
|
||||
if let Some(info) = chained {
|
||||
// Swap to the chained source's done flag.
|
||||
current_done = info.source_done;
|
||||
// Tracking was already updated in audio_play_gapless_chain;
|
||||
// just update replay gain fields in case they differ.
|
||||
{
|
||||
let mut cur = current_arc.lock().unwrap();
|
||||
cur.replay_gain_linear = info.replay_gain_linear;
|
||||
cur.base_volume = info.base_volume;
|
||||
// Reset play_started to now — the old track physically
|
||||
// ended, the new one is now actively producing samples.
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
app.emit("audio:playing", info.duration_secs).ok();
|
||||
near_end_ticks = 0;
|
||||
continue;
|
||||
}
|
||||
// Current source exhausted but no chain queued — the Sink is
|
||||
// likely draining; audio:ended will fire on the next tick via
|
||||
// the near-end logic below.
|
||||
}
|
||||
|
||||
let (pos, dur, is_paused) = {
|
||||
let cur = current_arc.lock().unwrap();
|
||||
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
|
||||
};
|
||||
|
||||
app_clone
|
||||
.emit(
|
||||
"audio:progress",
|
||||
ProgressPayload { current_time: pos, duration: dur },
|
||||
)
|
||||
.ok();
|
||||
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
|
||||
|
||||
if is_paused {
|
||||
// Don't advance near-end counter while paused (stay put).
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
if dur > 1.0 && pos >= dur - 1.0 {
|
||||
if dur > end_threshold && pos >= dur - end_threshold {
|
||||
near_end_ticks += 1;
|
||||
if near_end_ticks >= 2 {
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
app_clone.emit("audio:ended", ()).ok();
|
||||
app.emit("audio:ended", ()).ok();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -388,8 +924,6 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -422,6 +956,7 @@ pub fn audio_resume(state: State<'_, AudioEngine>) {
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() {
|
||||
sink.stop();
|
||||
@@ -434,6 +969,15 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
// Seeking far back invalidates any pending gapless chain.
|
||||
let cur_pos = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.position()
|
||||
};
|
||||
if seconds < cur_pos - 1.0 {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
||||
@@ -450,9 +994,10 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,3 +1008,36 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngin
|
||||
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,62 @@ 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.6.0")
|
||||
.send()
|
||||
.await
|
||||
} else {
|
||||
client
|
||||
.post("https://ws.audioscrobbler.com/2.0/")
|
||||
.form(&map)
|
||||
.header("User-Agent", "psysonic/1.6.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)
|
||||
}
|
||||
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
@@ -126,6 +182,11 @@ pub fn run() {
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
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,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.4.5",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"version": "1.8.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -16,8 +16,8 @@
|
||||
"title": "Psysonic",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
|
||||
+8
-1
@@ -29,7 +29,10 @@ import SearchResults from './pages/SearchResults';
|
||||
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';
|
||||
@@ -138,11 +141,13 @@ function AppShell() {
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleQueue}
|
||||
title={t('player.toggleQueue')}
|
||||
data-tooltip={t('player.toggleQueue')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
|
||||
</button>
|
||||
@@ -189,6 +194,8 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,13 @@ export interface SubsonicSong {
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
|
||||
@@ -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,6 +99,7 @@ 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);
|
||||
@@ -104,6 +107,13 @@ export default function AlbumHeader({
|
||||
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 +131,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>
|
||||
)}
|
||||
|
||||
@@ -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,9 +1,11 @@
|
||||
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';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
@@ -19,8 +21,9 @@ 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 } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -74,25 +77,16 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||
try {
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(albumName)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
}
|
||||
@@ -157,6 +151,21 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -218,6 +227,21 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> {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,28 @@
|
||||
import React, { useEffect } from 'react';
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function DownloadFolderModal() {
|
||||
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
|
||||
if (selected && typeof selected === 'string') setFolder(selected);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
|
||||
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 0 12px' }}>
|
||||
<div className="download-folder-pick-row">
|
||||
<span className="download-folder-path">
|
||||
{folder || t('common.noFolderSelected')}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={15} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="download-remember-row">
|
||||
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
|
||||
<span>{t('common.rememberDownloadFolder')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="download-modal-actions">
|
||||
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => confirm(setDownloadFolder)}
|
||||
disabled={!folder}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
|
||||
// ─── Frequency response canvas ────────────────────────────────────────────────
|
||||
|
||||
@@ -11,31 +13,31 @@ const EQ_Q = 1.41;
|
||||
function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
|
||||
if (Math.abs(gainDb) < 0.01) return 0;
|
||||
const w0 = (2 * Math.PI * centerHz) / sampleRate;
|
||||
const A = Math.pow(10, gainDb / 40);
|
||||
const A = Math.pow(10, gainDb / 40);
|
||||
const alpha = Math.sin(w0) / (2 * EQ_Q);
|
||||
const b0 = 1 + alpha * A;
|
||||
const b0 = 1 + alpha * A;
|
||||
const b1 = -2 * Math.cos(w0);
|
||||
const b2 = 1 - alpha * A;
|
||||
const a0 = 1 + alpha / A;
|
||||
const b2 = 1 - alpha * A;
|
||||
const a0 = 1 + alpha / A;
|
||||
const a1 = -2 * Math.cos(w0);
|
||||
const a2 = 1 - alpha / A;
|
||||
const w = (2 * Math.PI * freq) / sampleRate;
|
||||
const a2 = 1 - alpha / A;
|
||||
const w = (2 * Math.PI * freq) / sampleRate;
|
||||
const cosW = Math.cos(w), sinW = Math.sin(w);
|
||||
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
|
||||
const numRe = b0 + b1 * cosW + b2 * cos2W;
|
||||
const numIm = - b1 * sinW - b2 * sin2W;
|
||||
const numIm = - b1 * sinW - b2 * sin2W;
|
||||
const denRe = a0 + a1 * cosW + a2 * cos2W;
|
||||
const denIm = - a1 * sinW - a2 * sin2W;
|
||||
const denIm = - a1 * sinW - a2 * sin2W;
|
||||
const numMag2 = numRe * numRe + numIm * numIm;
|
||||
const denMag2 = denRe * denRe + denIm * denIm;
|
||||
return 10 * Math.log10(numMag2 / denMag2);
|
||||
}
|
||||
|
||||
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string) {
|
||||
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.offsetWidth;
|
||||
const H = canvas.offsetHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(dpr, dpr);
|
||||
@@ -52,7 +54,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
|
||||
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0d0d12';
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Grid: dB lines
|
||||
@@ -64,7 +66,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(W - padR, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
|
||||
@@ -123,13 +125,70 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ─── Custom vertical fader (no native range input) ────────────────────────────
|
||||
|
||||
const GAIN_MIN = -12, GAIN_MAX = 12;
|
||||
|
||||
interface FaderProps {
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
|
||||
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
|
||||
|
||||
const updateFromY = useCallback((clientY: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
dragging.current = true;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragging.current || disabled) return;
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => { dragging.current = false; };
|
||||
|
||||
const thumbPct = gainToPct(value) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="eq-fader-custom"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="eq-track-line" />
|
||||
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Equalizer() {
|
||||
const { t } = useTranslation();
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const customPresets = useEqStore(s => s.customPresets);
|
||||
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
|
||||
@@ -137,13 +196,17 @@ export default function Equalizer() {
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
drawCurve(canvas, gains, accent);
|
||||
}, [gains]);
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
|
||||
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
|
||||
drawCurve(canvas, gains, accent, bg, text);
|
||||
}, [gains, theme]);
|
||||
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
@@ -153,8 +216,8 @@ export default function Equalizer() {
|
||||
return () => ro.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -178,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>
|
||||
@@ -245,14 +303,10 @@ export default function Equalizer() {
|
||||
</span>
|
||||
<div className="eq-fader-track">
|
||||
<div className="eq-zero-mark" />
|
||||
<input
|
||||
type="range"
|
||||
className="eq-fader"
|
||||
min={-12} max={12} step={0.5}
|
||||
<VerticalFader
|
||||
value={gains[i]}
|
||||
onChange={e => setBandGain(i, parseFloat(e.target.value))}
|
||||
disabled={!enabled}
|
||||
aria-label={`${band.label} Hz`}
|
||||
onChange={v => setBandGain(i, v)}
|
||||
/>
|
||||
</div>
|
||||
<span className="eq-freq-label">{band.label}</span>
|
||||
|
||||
@@ -193,9 +193,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<>
|
||||
<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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
|
||||
} 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';
|
||||
|
||||
@@ -20,11 +22,14 @@ function formatTime(seconds: number): string {
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
@@ -35,7 +40,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 (
|
||||
@@ -84,6 +89,17 @@ export default function PlayerBar() {
|
||||
{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 */}
|
||||
@@ -125,6 +141,16 @@ export default function PlayerBar() {
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
onClick={() => setEqOpen(v => !v)}
|
||||
aria-label="Equalizer"
|
||||
data-tooltip="Equalizer"
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
@@ -149,6 +175,22 @@ export default function PlayerBar() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EQ Popup */}
|
||||
{eqOpen && (
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
||||
<div className="eq-popup">
|
||||
<div className="eq-popup-header">
|
||||
<span className="eq-popup-title">Equalizer</span>
|
||||
<button className="eq-popup-close" onClick={() => setEqOpen(false)} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Equalizer />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
+105
-34
@@ -1,10 +1,11 @@
|
||||
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 } 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';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -130,6 +131,30 @@ 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 [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);
|
||||
@@ -255,8 +280,8 @@ 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);
|
||||
@@ -266,31 +291,12 @@ export default function QueuePanel() {
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
return (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
|
||||
{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 && (
|
||||
@@ -301,6 +307,13 @@ export default function QueuePanel() {
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
{(currentTrack.bitRate || currentTrack.suffix) && (
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix
|
||||
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
|
||||
: currentTrack.suffix?.toUpperCase() ?? ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
@@ -319,20 +332,78 @@ export default function QueuePanel() {
|
||||
{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>
|
||||
{renderStars(currentTrack.userRating)}
|
||||
</div>
|
||||
{renderStars(currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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={() => 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 {
|
||||
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}>
|
||||
|
||||
+16
-11
@@ -10,7 +10,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" />
|
||||
);
|
||||
|
||||
const navItems = [
|
||||
@@ -38,7 +38,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>
|
||||
);
|
||||
@@ -100,9 +100,10 @@ export default function Sidebar({
|
||||
<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')}
|
||||
className="collapse-btn"
|
||||
onClick={toggleCollapse}
|
||||
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
|
||||
@@ -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,155 @@
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface ThemeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Catppuccin',
|
||||
themes: [
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Nord',
|
||||
themes: [
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Retro',
|
||||
themes: [
|
||||
{ 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: '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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Tokyo Night',
|
||||
themes: [
|
||||
{ id: 'tokyo-night', label: 'Standard', bg: '#1a1b26', card: '#24283b', accent: '#7aa2f7' },
|
||||
{ id: 'tokyo-night-storm', label: 'Storm', bg: '#24283b', card: '#2f334d', accent: '#7aa2f7' },
|
||||
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Psysonic Themes',
|
||||
themes: [
|
||||
{ id: 'classic-winamp', label: 'Classic Winamp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
|
||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#9e9a92' },
|
||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ThemePicker({ value, onChange }: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{THEME_GROUPS.map(({ group, themes }) => (
|
||||
<div key={group}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
{group}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{themes.map((t) => {
|
||||
const isActive = value === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '46px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
position: 'relative',
|
||||
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||
transition: 'outline-color 0.15s, box-shadow 0.15s',
|
||||
}}>
|
||||
{/* main bg */}
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
{/* card tone */}
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
{/* accent bar */}
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: t.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 });
|
||||
|
||||
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 = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (target) setTooltip(null);
|
||||
};
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseout', onOut);
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseout', onOut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -59,8 +59,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 +96,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;
|
||||
|
||||
+1151
-27
File diff suppressed because it is too large
Load Diff
+10
-14
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
@@ -24,6 +25,7 @@ export default function AlbumDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
@@ -106,6 +108,11 @@ export default function AlbumDetail() {
|
||||
const handleDownload = async () => {
|
||||
if (!album) return;
|
||||
const { name, id: albumId } = album.album;
|
||||
|
||||
// Ask for folder before starting download if not already set
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
@@ -133,20 +140,9 @@ export default function AlbumDetail() {
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${sanitizeFilename(name)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
|
||||
+141
-17
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
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();
|
||||
@@ -45,12 +41,17 @@ export default function ArtistDetail() {
|
||||
const navigate = useNavigate();
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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);
|
||||
@@ -60,13 +61,14 @@ export default function ArtistDetail() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setFeaturedAlbums([]);
|
||||
getArtist(id).then(artistData => {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => [])
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
]);
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
@@ -78,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);
|
||||
@@ -161,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)" />
|
||||
)}
|
||||
@@ -296,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>
|
||||
|
||||
@@ -308,6 +412,26 @@ export default function ArtistDetail() {
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
|
||||
{/* Also Featured On */}
|
||||
{(featuredLoading || featuredAlbums.length > 0) && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -45,6 +45,7 @@ export default function Help() {
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -100,7 +101,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">
|
||||
|
||||
@@ -71,9 +71,6 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
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>
|
||||
);
|
||||
@@ -319,7 +316,7 @@ export default function NowPlaying() {
|
||||
<button
|
||||
onClick={toggleStar}
|
||||
className="np-star-btn"
|
||||
title={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
@@ -347,16 +344,16 @@ export default function NowPlaying() {
|
||||
)}
|
||||
</div>
|
||||
<div className="np-queue-actions">
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" title={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" title={t('queue.save')} disabled={queue.length === 0}>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.save')} disabled={queue.length === 0}>
|
||||
<Save size={15} />
|
||||
</button>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" title={t('queue.load')}>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
<button onClick={clearQueue} className="np-action-btn" title={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<button onClick={clearQueue} className="np-action-btn" data-tooltip={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -412,7 +409,7 @@ export default function NowPlaying() {
|
||||
<button
|
||||
className="np-queue-remove"
|
||||
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
|
||||
title={t('queue.remove')}
|
||||
data-tooltip={t('queue.remove')}
|
||||
><X size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+668
-355
File diff suppressed because it is too large
Load Diff
+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: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,11 +27,17 @@ interface AuthState {
|
||||
downloadFolder: string;
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
replayGainMode: 'track' | 'album';
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionError: string | null;
|
||||
lastfmSessionError: boolean;
|
||||
|
||||
// Actions
|
||||
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
|
||||
@@ -42,12 +48,20 @@ 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;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
setExcludeAudiobooks: (v: boolean) => void;
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
setReplayGainMode: (v: 'track' | 'album') => void;
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
logout: () => void;
|
||||
|
||||
// Derived
|
||||
@@ -74,9 +88,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
downloadFolder: '',
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'track',
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
lastfmSessionError: false,
|
||||
|
||||
addServer: (profile) => {
|
||||
const id = generateId();
|
||||
@@ -111,12 +131,25 @@ 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 }),
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
|
||||
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
|
||||
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }),
|
||||
setReplayGainMode: (v) => set({ replayGainMode: v }),
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
|
||||
logout: () => set({ isLoggedIn: false }),
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Module-level callback — not in Zustand state to avoid serialization issues
|
||||
let _resolve: ((folder: string | null) => void) | null = null;
|
||||
|
||||
interface DownloadModalStore {
|
||||
isOpen: boolean;
|
||||
folder: string;
|
||||
remember: boolean;
|
||||
requestFolder: () => Promise<string | null>;
|
||||
setFolder: (f: string) => void;
|
||||
setRemember: (r: boolean) => void;
|
||||
confirm: (setDownloadFolder: (v: string) => void) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const useDownloadModalStore = create<DownloadModalStore>((set, get) => ({
|
||||
isOpen: false,
|
||||
folder: '',
|
||||
remember: false,
|
||||
|
||||
requestFolder: () =>
|
||||
new Promise<string | null>(resolve => {
|
||||
_resolve = resolve;
|
||||
set({ isOpen: true, folder: '', remember: false });
|
||||
}),
|
||||
|
||||
setFolder: (folder) => set({ folder }),
|
||||
setRemember: (remember) => set({ remember }),
|
||||
|
||||
confirm: (setDownloadFolder) => {
|
||||
const { folder, remember } = get();
|
||||
if (!folder) return;
|
||||
if (remember) setDownloadFolder(folder);
|
||||
_resolve?.(folder);
|
||||
_resolve = null;
|
||||
set({ isOpen: false });
|
||||
},
|
||||
|
||||
cancel: () => {
|
||||
_resolve?.(null);
|
||||
_resolve = null;
|
||||
set({ isOpen: false });
|
||||
},
|
||||
}));
|
||||
+172
-4
@@ -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, getPlayQueue, savePlayQueue, reportNowPlaying, SubsonicSong } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
export interface Track {
|
||||
@@ -19,6 +20,30 @@ export interface Track {
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
userRating?: number;
|
||||
replayGainTrackDb?: number;
|
||||
replayGainAlbumDb?: number;
|
||||
replayGainPeak?: number;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
return {
|
||||
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,
|
||||
replayGainTrackDb: song.replayGain?.trackGain,
|
||||
replayGainAlbumDb: song.replayGain?.albumGain,
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
};
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
@@ -31,6 +56,8 @@ interface PlayerState {
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
scrobbled: boolean;
|
||||
lastfmLoved: boolean;
|
||||
lastfmLovedCache: Record<string, boolean>;
|
||||
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
pause: () => void;
|
||||
@@ -59,6 +86,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: {
|
||||
@@ -119,11 +151,50 @@ 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% directly via Last.fm
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (scrobblingEnabled && lastfmSessionKey) {
|
||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-buffer / pre-chain next track when 30 s remain.
|
||||
const { gaplessEnabled } = useAuthStore.getState();
|
||||
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) {
|
||||
const nextUrl = buildStreamUrl(nextTrack.id);
|
||||
if (gaplessEnabled) {
|
||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
||||
// advance. By the time the track boundary arrives, the next source is
|
||||
// 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +238,25 @@ export function initAudioListeners(): () => void {
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
];
|
||||
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
usePlayerStore.getState().syncLastfmLovedTracks();
|
||||
|
||||
// Initial sync of audio settings to Rust engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
|
||||
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(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -186,6 +275,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTime: 0,
|
||||
volume: 0.8,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
lastfmLovedCache: {},
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
repeatMode: 'off',
|
||||
@@ -202,6 +293,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] };
|
||||
@@ -234,14 +374,22 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
});
|
||||
|
||||
const url = buildStreamUrl(track.id);
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
@@ -252,7 +400,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);
|
||||
},
|
||||
|
||||
@@ -277,10 +437,17 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const gen = ++playGeneration;
|
||||
const vol = get().volume;
|
||||
set({ isPlaying: true });
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = authStateCold.replayGainEnabled
|
||||
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url: buildStreamUrl(currentTrack.id),
|
||||
volume: vol,
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
@@ -456,6 +623,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' | 'classic-winamp' | 'poison' | 'nucleo' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
+1492
-436
File diff suppressed because it is too large
Load Diff
+201
-10
@@ -62,7 +62,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;
|
||||
@@ -439,7 +439,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 +447,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 */
|
||||
@@ -489,6 +490,74 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-eq-btn {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.player-eq-btn:hover,
|
||||
.player-eq-btn.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── EQ Popup ─── */
|
||||
.eq-popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.eq-popup {
|
||||
position: fixed;
|
||||
z-index: 201;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(700px, 92vw);
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.eq-popup .eq-wrap {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.eq-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.eq-popup-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.eq-popup-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.eq-popup-close:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-app);
|
||||
}
|
||||
|
||||
/* ─── Queue Panel ─── */
|
||||
.queue-panel {
|
||||
@@ -513,6 +582,121 @@
|
||||
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 {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
@@ -534,6 +718,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.queue-current-cover img {
|
||||
@@ -567,15 +752,21 @@
|
||||
}
|
||||
|
||||
.queue-current-tech {
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
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: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
padding: 3px 6px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
|
||||
+793
-10
@@ -253,9 +253,11 @@
|
||||
--accent-glow: rgba(136, 192, 208, 0.3);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--border-dropdown: #576070;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
@@ -361,9 +363,11 @@
|
||||
--accent-glow: rgba(136, 192, 208, 0.35);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #7898b4;
|
||||
--border: #2d3f52;
|
||||
--border-subtle: #253545;
|
||||
--text-muted: #7898b4;
|
||||
--border: #2d3f52;
|
||||
--border-subtle: #253545;
|
||||
--border-dropdown: #3d546e;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.6);
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
@@ -414,14 +418,791 @@
|
||||
--accent-glow: rgba(180, 142, 173, 0.3);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--border-dropdown: #576070;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
}
|
||||
|
||||
/* ─── Poison ─── */
|
||||
[data-theme='poison'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%231bd655%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette — charcoal grays + phosphor green ── */
|
||||
--ctp-crust: #0e0e0e;
|
||||
--ctp-mantle: #161616;
|
||||
--ctp-base: #1f1f1f;
|
||||
--ctp-surface0: #282828;
|
||||
--ctp-surface1: #323232;
|
||||
--ctp-surface2: #3e3e3e;
|
||||
--ctp-overlay0: #525252;
|
||||
--ctp-overlay1: #686868;
|
||||
--ctp-overlay2: #848484;
|
||||
--ctp-text: #d0d0cc;
|
||||
--ctp-subtext1: #a8a8a4;
|
||||
--ctp-subtext0: #787874;
|
||||
/* Phosphor green accent — the Winamp LED display color */
|
||||
--ctp-mauve: #1bd655;
|
||||
--ctp-lavender: #28e860;
|
||||
--ctp-green: #1bd655;
|
||||
--ctp-teal: #1ab8a8;
|
||||
--ctp-sky: #58a8e0;
|
||||
--ctp-blue: #4888d0;
|
||||
--ctp-sapphire: #3870b8;
|
||||
--ctp-pink: #c86888;
|
||||
--ctp-flamingo: #b85858;
|
||||
--ctp-rosewater: #c88080;
|
||||
--ctp-yellow: #c8a830;
|
||||
--ctp-peach: #c07838;
|
||||
--ctp-maroon: #a03030;
|
||||
--ctp-red: #c03030;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #1f1f1f;
|
||||
--bg-sidebar: #161616;
|
||||
--bg-card: #282828;
|
||||
--bg-hover: #323232;
|
||||
--bg-player: #0e0e0e;
|
||||
--bg-glass: rgba(22, 22, 22, 0.90);
|
||||
--accent: #1bd655;
|
||||
--accent-dim: rgba(27, 214, 85, 0.12);
|
||||
--accent-glow: rgba(27, 214, 85, 0.35);
|
||||
--text-primary: #d0d0cc;
|
||||
--text-secondary:#a8a8a4;
|
||||
--text-muted: #686866;
|
||||
--border: #3a3a38;
|
||||
--border-subtle: #282826;
|
||||
--positive: #1bd655;
|
||||
--warning: #c8a830;
|
||||
--danger: #c03030;
|
||||
}
|
||||
|
||||
/* ── Winamp: LED display glow + tactile buttons ── */
|
||||
[data-theme='poison'] .player-track-name {
|
||||
text-shadow: 0 0 8px rgba(27, 214, 85, 0.7), 0 0 2px rgba(27, 214, 85, 0.4);
|
||||
color: #1bd655;
|
||||
}
|
||||
|
||||
[data-theme='poison'] .player-track-artist {
|
||||
color: #1bd655;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
[data-theme='poison'] .btn {
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='poison'] .player-btn,
|
||||
[data-theme='poison'] .player-btn-primary {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Classic Winamp — Winamp 2.x Base Skin ─── */
|
||||
[data-theme='classic-winamp'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8c8d0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette ── */
|
||||
--ctp-crust: #000000;
|
||||
--ctp-mantle: #1e1e28;
|
||||
--ctp-base: #2b2b3a;
|
||||
--ctp-surface0: #353545;
|
||||
--ctp-surface1: #404055;
|
||||
--ctp-surface2: #4e4e65;
|
||||
--ctp-overlay0: #5a5a75;
|
||||
--ctp-overlay1: #6e6e88;
|
||||
--ctp-overlay2: #8a8a96;
|
||||
--ctp-text: #c8c8d0;
|
||||
--ctp-subtext1: #a0a0ac;
|
||||
--ctp-subtext0: #8a8a96;
|
||||
/* Winamp orange accent + iconic green glow */
|
||||
--ctp-mauve: #d4cc46;
|
||||
--ctp-lavender: #e0d860;
|
||||
--ctp-green: #00ff00;
|
||||
--ctp-teal: #00cc88;
|
||||
--ctp-sky: #5ab8e0;
|
||||
--ctp-blue: #4898d0;
|
||||
--ctp-sapphire: #3878b8;
|
||||
--ctp-pink: #c06888;
|
||||
--ctp-flamingo: #b85858;
|
||||
--ctp-rosewater: #c88080;
|
||||
--ctp-yellow: #e8c050;
|
||||
--ctp-peach: #d08840;
|
||||
--ctp-maroon: #a03030;
|
||||
--ctp-red: #c03030;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #2b2b3a;
|
||||
--bg-sidebar: #1e1e28;
|
||||
--bg-card: #353545;
|
||||
--bg-hover: #404055;
|
||||
--bg-player: #000000;
|
||||
--bg-glass: rgba(43, 43, 58, 0.90);
|
||||
--accent: #d4cc46;
|
||||
--accent-dim: rgba(212, 204, 70, 0.15);
|
||||
--accent-glow: rgba(212, 204, 70, 0.35);
|
||||
--volume-accent: #de9b35;
|
||||
--text-primary: #c8c8d0;
|
||||
--text-secondary:#a0a0ac;
|
||||
--text-muted: #6e6e88;
|
||||
--border: #5a5a75;
|
||||
--border-subtle: #353545;
|
||||
--positive: #00cc66;
|
||||
--warning: #d4cc46;
|
||||
--danger: #c03030;
|
||||
}
|
||||
|
||||
/* ── Classic Winamp: LCD display + bevel buttons ── */
|
||||
[data-theme='classic-winamp'] .player-track-name {
|
||||
color: #00ff00;
|
||||
text-shadow: 0 0 6px rgba(0, 255, 0, 0.65), 0 0 2px rgba(0, 255, 0, 0.4);
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
[data-theme='classic-winamp'] .player-track-artist {
|
||||
color: #00cc00;
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-theme='classic-winamp'] .btn {
|
||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
[data-theme='classic-winamp'] .player-btn,
|
||||
[data-theme='classic-winamp'] .player-btn-primary {
|
||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 2px 5px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ─── Nucleo — Classic HiFi / Winamp Brass ─── */
|
||||
[data-theme='nucleo'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%238c7356%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette ── */
|
||||
--ctp-crust: #c8a458;
|
||||
--ctp-mantle: #dfc08f;
|
||||
--ctp-base: #e9d3ac;
|
||||
--ctp-surface0: #eedbb8;
|
||||
--ctp-surface1: #f5e4c3;
|
||||
--ctp-surface2: #f9ecd4;
|
||||
--ctp-overlay0: #b09060;
|
||||
--ctp-overlay1: #987848;
|
||||
--ctp-overlay2: #805c30;
|
||||
--ctp-text: #2c1e10;
|
||||
--ctp-subtext1: #5a3e20;
|
||||
--ctp-subtext0: #8c7356;
|
||||
/* Accents — silver metal + muted brass tones */
|
||||
--ctp-mauve: #9e9a92; /* silver — primary accent */
|
||||
--ctp-lavender: #b8b4ac; /* lighter silver */
|
||||
--ctp-pink: #c06880;
|
||||
--ctp-flamingo: #c06050;
|
||||
--ctp-rosewater: #d08070;
|
||||
--ctp-blue: #5878a8; /* muted steel blue */
|
||||
--ctp-sapphire: #4868a0;
|
||||
--ctp-sky: #6898b8;
|
||||
--ctp-teal: #487878;
|
||||
--ctp-green: #587838; /* muted olive */
|
||||
--ctp-yellow: #a88020; /* dark burnished gold */
|
||||
--ctp-peach: #b87040;
|
||||
--ctp-maroon: #903030;
|
||||
--ctp-red: #a03828;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #f5e4c3;
|
||||
--bg-sidebar: #e9d3ac;
|
||||
--bg-card: #eedbb8;
|
||||
--bg-hover: #e3cc9e;
|
||||
--bg-player: #dfc08f;
|
||||
--bg-glass: rgba(245, 228, 195, 0.90);
|
||||
--accent: #9e9a92;
|
||||
--accent-dim: rgba(158, 154, 146, 0.15);
|
||||
--accent-glow: rgba(158, 154, 146, 0.35);
|
||||
--text-primary: #2c1e10;
|
||||
--text-secondary:#5a3e20;
|
||||
--text-muted: #8c7356;
|
||||
--border: #b89a69;
|
||||
--border-subtle: #dfc08f;
|
||||
--positive: #587838;
|
||||
--warning: #a88020;
|
||||
--danger: #a03828;
|
||||
}
|
||||
|
||||
/* ── Nucleo: skeuomorphic detail layer ── */
|
||||
[data-theme='nucleo'] .sidebar {
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.06) 0px,
|
||||
rgba(255,255,255,0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 5px
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .player-bar {
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.05) 0px,
|
||||
rgba(255,255,255,0.05) 1px,
|
||||
transparent 1px,
|
||||
transparent 5px
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .player-track-name {
|
||||
text-shadow: 0 0 10px rgba(255, 244, 219, 0.75), 0 0 2px rgba(255, 244, 219, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .btn {
|
||||
box-shadow: 0 2px 4px rgba(44, 30, 16, 0.20), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .btn-surface {
|
||||
background: #c8a860;
|
||||
color: #2c1e10;
|
||||
}
|
||||
[data-theme='nucleo'] .btn-surface:hover { background: #b89448; }
|
||||
|
||||
[data-theme='nucleo'] .btn-ghost:hover {
|
||||
background: #c8a860;
|
||||
color: #2c1e10;
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .player-btn,
|
||||
[data-theme='nucleo'] .player-btn-primary {
|
||||
box-shadow: 0 2px 5px rgba(44, 30, 16, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ─── Psychowave — Synthwave / Retrowave ─── */
|
||||
[data-theme='psychowave'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b09ee0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette ── */
|
||||
--ctp-crust: #0d0c1a;
|
||||
--ctp-mantle: #110f22;
|
||||
--ctp-base: #161428;
|
||||
--ctp-surface0: #1f1c38;
|
||||
--ctp-surface1: #2a2750;
|
||||
--ctp-surface2: #383468;
|
||||
--ctp-overlay0: #4a4580;
|
||||
--ctp-overlay1: #6460a8;
|
||||
--ctp-overlay2: #8078c8;
|
||||
/* Text */
|
||||
--ctp-text: #e6e0f6;
|
||||
--ctp-subtext1: #b09ee0;
|
||||
--ctp-subtext0: #8878c0;
|
||||
/* Accents — muted synthwave palette */
|
||||
--ctp-mauve: #a06ae0; /* soft violet — primary accent */
|
||||
--ctp-lavender: #7878d8; /* muted indigo — gradient end */
|
||||
--ctp-pink: #c060a8; /* dusty rose */
|
||||
--ctp-flamingo: #c07090;
|
||||
--ctp-rosewater: #c89090;
|
||||
--ctp-blue: #58a8e8; /* steel blue */
|
||||
--ctp-sapphire: #4880c8;
|
||||
--ctp-sky: #60c0d8;
|
||||
--ctp-teal: #40a8a8;
|
||||
--ctp-green: #58c878; /* soft mint green */
|
||||
--ctp-yellow: #c8a848; /* muted gold */
|
||||
--ctp-peach: #c87848;
|
||||
--ctp-maroon: #c05868;
|
||||
--ctp-red: #b04060;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #161428;
|
||||
--bg-sidebar: #110f22;
|
||||
--bg-card: #1f1c38;
|
||||
--bg-hover: #2a2750;
|
||||
--bg-player: #0d0c1a;
|
||||
--bg-glass: rgba(22, 20, 40, 0.85);
|
||||
--accent: #a06ae0;
|
||||
--accent-dim: rgba(160, 106, 224, 0.12);
|
||||
--accent-glow: rgba(160, 106, 224, 0.30);
|
||||
--text-primary: #e6e0f6;
|
||||
--text-secondary:#b09ee0;
|
||||
--text-muted: #706898;
|
||||
--border: #383468;
|
||||
--border-subtle: #1f1c38;
|
||||
--positive: #58c878;
|
||||
--warning: #c8a848;
|
||||
--danger: #b04060;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Dark Hard ─── */
|
||||
[data-theme='gruvbox-dark-hard'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1d2021;
|
||||
--ctp-mantle: #1d2021;
|
||||
--ctp-base: #282828;
|
||||
--ctp-surface0: #3c3836;
|
||||
--ctp-surface1: #504945;
|
||||
--ctp-surface2: #665c54;
|
||||
--ctp-overlay0: #7c6f64;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #a89984;
|
||||
--ctp-text: #ebdbb2;
|
||||
--ctp-subtext1: #d5c4a1;
|
||||
--ctp-subtext0: #bdae93;
|
||||
--ctp-mauve: #d3869b;
|
||||
--ctp-lavender: #d3869b;
|
||||
--ctp-pink: #d3869b;
|
||||
--ctp-flamingo: #fb4934;
|
||||
--ctp-rosewater: #fb4934;
|
||||
--ctp-blue: #83a598;
|
||||
--ctp-sapphire: #83a598;
|
||||
--ctp-sky: #8ec07c;
|
||||
--ctp-teal: #8ec07c;
|
||||
--ctp-green: #b8bb26;
|
||||
--ctp-yellow: #fabd2f;
|
||||
--ctp-peach: #fe8019;
|
||||
--ctp-maroon: #fb4934;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #1d2021;
|
||||
--bg-sidebar: #141617;
|
||||
--bg-card: #3c3836;
|
||||
--bg-hover: #504945;
|
||||
--bg-player: #1d2021;
|
||||
--bg-glass: rgba(29, 32, 33, 0.82);
|
||||
--accent: #fabd2f;
|
||||
--accent-dim: rgba(250, 189, 47, 0.15);
|
||||
--accent-glow: rgba(250, 189, 47, 0.3);
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary:#d5c4a1;
|
||||
--text-muted: #928374;
|
||||
--border: #504945;
|
||||
--border-subtle: #3c3836;
|
||||
--border-dropdown: #665c54;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.6);
|
||||
--positive: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--danger: #fb4934;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Dark Medium ─── */
|
||||
[data-theme='gruvbox-dark-medium'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1d2021;
|
||||
--ctp-mantle: #1d2021;
|
||||
--ctp-base: #282828;
|
||||
--ctp-surface0: #3c3836;
|
||||
--ctp-surface1: #504945;
|
||||
--ctp-surface2: #665c54;
|
||||
--ctp-overlay0: #7c6f64;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #a89984;
|
||||
--ctp-text: #ebdbb2;
|
||||
--ctp-subtext1: #d5c4a1;
|
||||
--ctp-subtext0: #bdae93;
|
||||
--ctp-mauve: #d3869b;
|
||||
--ctp-lavender: #d3869b;
|
||||
--ctp-pink: #d3869b;
|
||||
--ctp-flamingo: #fb4934;
|
||||
--ctp-rosewater: #fb4934;
|
||||
--ctp-blue: #83a598;
|
||||
--ctp-sapphire: #83a598;
|
||||
--ctp-sky: #8ec07c;
|
||||
--ctp-teal: #8ec07c;
|
||||
--ctp-green: #b8bb26;
|
||||
--ctp-yellow: #fabd2f;
|
||||
--ctp-peach: #fe8019;
|
||||
--ctp-maroon: #fb4934;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #282828;
|
||||
--bg-sidebar: #1d2021;
|
||||
--bg-card: #3c3836;
|
||||
--bg-hover: #504945;
|
||||
--bg-player: #1d2021;
|
||||
--bg-glass: rgba(40, 40, 40, 0.82);
|
||||
--accent: #fabd2f;
|
||||
--accent-dim: rgba(250, 189, 47, 0.15);
|
||||
--accent-glow: rgba(250, 189, 47, 0.3);
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary:#d5c4a1;
|
||||
--text-muted: #928374;
|
||||
--border: #504945;
|
||||
--border-subtle: #3c3836;
|
||||
--border-dropdown: #665c54;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--danger: #fb4934;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Dark Soft ─── */
|
||||
[data-theme='gruvbox-dark-soft'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1d2021;
|
||||
--ctp-mantle: #282828;
|
||||
--ctp-base: #32302f;
|
||||
--ctp-surface0: #3c3836;
|
||||
--ctp-surface1: #504945;
|
||||
--ctp-surface2: #665c54;
|
||||
--ctp-overlay0: #7c6f64;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #a89984;
|
||||
--ctp-text: #ebdbb2;
|
||||
--ctp-subtext1: #d5c4a1;
|
||||
--ctp-subtext0: #bdae93;
|
||||
--ctp-mauve: #d3869b;
|
||||
--ctp-lavender: #d3869b;
|
||||
--ctp-pink: #d3869b;
|
||||
--ctp-flamingo: #fb4934;
|
||||
--ctp-rosewater: #fb4934;
|
||||
--ctp-blue: #83a598;
|
||||
--ctp-sapphire: #83a598;
|
||||
--ctp-sky: #8ec07c;
|
||||
--ctp-teal: #8ec07c;
|
||||
--ctp-green: #b8bb26;
|
||||
--ctp-yellow: #fabd2f;
|
||||
--ctp-peach: #fe8019;
|
||||
--ctp-maroon: #fb4934;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #32302f;
|
||||
--bg-sidebar: #282828;
|
||||
--bg-card: #45403d;
|
||||
--bg-hover: #504945;
|
||||
--bg-player: #282828;
|
||||
--bg-glass: rgba(50, 48, 47, 0.82);
|
||||
--accent: #fabd2f;
|
||||
--accent-dim: rgba(250, 189, 47, 0.15);
|
||||
--accent-glow: rgba(250, 189, 47, 0.3);
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary:#d5c4a1;
|
||||
--text-muted: #928374;
|
||||
--border: #504945;
|
||||
--border-subtle: #3c3836;
|
||||
--border-dropdown: #665c54;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.5);
|
||||
--positive: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--danger: #fb4934;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Light Hard ─── */
|
||||
[data-theme='gruvbox-light-hard'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #ebdbb2;
|
||||
--ctp-mantle: #f2e5bc;
|
||||
--ctp-base: #f9f5d7;
|
||||
--ctp-surface0: #f2e5bc;
|
||||
--ctp-surface1: #ebdbb2;
|
||||
--ctp-surface2: #d5c4a1;
|
||||
--ctp-overlay0: #bdae93;
|
||||
--ctp-overlay1: #a89984;
|
||||
--ctp-overlay2: #928374;
|
||||
--ctp-text: #3c3836;
|
||||
--ctp-subtext1: #504945;
|
||||
--ctp-subtext0: #665c54;
|
||||
--ctp-mauve: #8f3f71;
|
||||
--ctp-lavender: #8f3f71;
|
||||
--ctp-pink: #8f3f71;
|
||||
--ctp-flamingo: #9d0006;
|
||||
--ctp-rosewater: #cc241d;
|
||||
--ctp-blue: #076678;
|
||||
--ctp-sapphire: #076678;
|
||||
--ctp-sky: #427b58;
|
||||
--ctp-teal: #427b58;
|
||||
--ctp-green: #79740e;
|
||||
--ctp-yellow: #b57614;
|
||||
--ctp-peach: #af3a03;
|
||||
--ctp-maroon: #9d0006;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #f9f5d7;
|
||||
--bg-sidebar: #f2e5bc;
|
||||
--bg-card: #f2e5bc;
|
||||
--bg-hover: #ebdbb2;
|
||||
--bg-player: #f2e5bc;
|
||||
--bg-glass: rgba(249, 245, 215, 0.9);
|
||||
--accent: #b57614;
|
||||
--accent-dim: rgba(181, 118, 20, 0.15);
|
||||
--accent-glow: rgba(181, 118, 20, 0.25);
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary:#504945;
|
||||
--text-muted: #7c6f64;
|
||||
--border: #d5c4a1;
|
||||
--border-subtle: #ebdbb2;
|
||||
--positive: #79740e;
|
||||
--warning: #b57614;
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Light Medium ─── */
|
||||
[data-theme='gruvbox-light-medium'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #ebdbb2;
|
||||
--ctp-mantle: #f2e5bc;
|
||||
--ctp-base: #fbf1c7;
|
||||
--ctp-surface0: #f2e5bc;
|
||||
--ctp-surface1: #ebdbb2;
|
||||
--ctp-surface2: #d5c4a1;
|
||||
--ctp-overlay0: #bdae93;
|
||||
--ctp-overlay1: #a89984;
|
||||
--ctp-overlay2: #928374;
|
||||
--ctp-text: #3c3836;
|
||||
--ctp-subtext1: #504945;
|
||||
--ctp-subtext0: #665c54;
|
||||
--ctp-mauve: #8f3f71;
|
||||
--ctp-lavender: #8f3f71;
|
||||
--ctp-pink: #8f3f71;
|
||||
--ctp-flamingo: #9d0006;
|
||||
--ctp-rosewater: #cc241d;
|
||||
--ctp-blue: #076678;
|
||||
--ctp-sapphire: #076678;
|
||||
--ctp-sky: #427b58;
|
||||
--ctp-teal: #427b58;
|
||||
--ctp-green: #79740e;
|
||||
--ctp-yellow: #b57614;
|
||||
--ctp-peach: #af3a03;
|
||||
--ctp-maroon: #9d0006;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #fbf1c7;
|
||||
--bg-sidebar: #f2e5bc;
|
||||
--bg-card: #f2e5bc;
|
||||
--bg-hover: #ebdbb2;
|
||||
--bg-player: #f2e5bc;
|
||||
--bg-glass: rgba(251, 241, 199, 0.9);
|
||||
--accent: #b57614;
|
||||
--accent-dim: rgba(181, 118, 20, 0.15);
|
||||
--accent-glow: rgba(181, 118, 20, 0.25);
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary:#504945;
|
||||
--text-muted: #7c6f64;
|
||||
--border: #d5c4a1;
|
||||
--border-subtle: #ebdbb2;
|
||||
--positive: #79740e;
|
||||
--warning: #b57614;
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Light Soft ─── */
|
||||
[data-theme='gruvbox-light-soft'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #ebdbb2;
|
||||
--ctp-mantle: #f2e5bc;
|
||||
--ctp-base: #f2e5bc;
|
||||
--ctp-surface0: #ebdbb2;
|
||||
--ctp-surface1: #d5c4a1;
|
||||
--ctp-surface2: #bdae93;
|
||||
--ctp-overlay0: #a89984;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #7c6f64;
|
||||
--ctp-text: #3c3836;
|
||||
--ctp-subtext1: #504945;
|
||||
--ctp-subtext0: #665c54;
|
||||
--ctp-mauve: #8f3f71;
|
||||
--ctp-lavender: #8f3f71;
|
||||
--ctp-pink: #8f3f71;
|
||||
--ctp-flamingo: #9d0006;
|
||||
--ctp-rosewater: #cc241d;
|
||||
--ctp-blue: #076678;
|
||||
--ctp-sapphire: #076678;
|
||||
--ctp-sky: #427b58;
|
||||
--ctp-teal: #427b58;
|
||||
--ctp-green: #79740e;
|
||||
--ctp-yellow: #b57614;
|
||||
--ctp-peach: #af3a03;
|
||||
--ctp-maroon: #9d0006;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #f2e5bc;
|
||||
--bg-sidebar: #ebdbb2;
|
||||
--bg-card: #ebdbb2;
|
||||
--bg-hover: #d5c4a1;
|
||||
--bg-player: #ebdbb2;
|
||||
--bg-glass: rgba(242, 229, 188, 0.9);
|
||||
--accent: #b57614;
|
||||
--accent-dim: rgba(181, 118, 20, 0.15);
|
||||
--accent-glow: rgba(181, 118, 20, 0.25);
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary:#504945;
|
||||
--text-muted: #7c6f64;
|
||||
--border: #d5c4a1;
|
||||
--border-subtle: #ebdbb2;
|
||||
--positive: #79740e;
|
||||
--warning: #b57614;
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ─── Tokyo Night ─── */
|
||||
[data-theme='tokyo-night'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #16161e;
|
||||
--ctp-mantle: #1a1b26;
|
||||
--ctp-base: #1a1b26;
|
||||
--ctp-surface0: #24283b;
|
||||
--ctp-surface1: #2f334d;
|
||||
--ctp-surface2: #3b3f5c;
|
||||
--ctp-overlay0: #414868;
|
||||
--ctp-overlay1: #565f89;
|
||||
--ctp-overlay2: #6272a4;
|
||||
--ctp-text: #c0caf5;
|
||||
--ctp-subtext1: #a9b1d6;
|
||||
--ctp-subtext0: #9aa5ce;
|
||||
--ctp-mauve: #bb9af7;
|
||||
--ctp-lavender: #c0caf5;
|
||||
--ctp-pink: #f7768e;
|
||||
--ctp-flamingo: #ff9e64;
|
||||
--ctp-rosewater: #f7768e;
|
||||
--ctp-blue: #7aa2f7;
|
||||
--ctp-sapphire: #7dcfff;
|
||||
--ctp-sky: #7dcfff;
|
||||
--ctp-teal: #2ac3de;
|
||||
--ctp-green: #9ece6a;
|
||||
--ctp-yellow: #e0af68;
|
||||
--ctp-peach: #ff9e64;
|
||||
--ctp-maroon: #f7768e;
|
||||
--ctp-red: #f7768e;
|
||||
|
||||
--bg-app: #1a1b26;
|
||||
--bg-sidebar: #16161e;
|
||||
--bg-card: #24283b;
|
||||
--bg-hover: #2f334d;
|
||||
--bg-player: #16161e;
|
||||
--bg-glass: rgba(26, 27, 38, 0.82);
|
||||
--accent: #7aa2f7;
|
||||
--accent-dim: rgba(122, 162, 247, 0.15);
|
||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary:#a9b1d6;
|
||||
--text-muted: #565f89;
|
||||
--border: #2f334d;
|
||||
--border-subtle: #24283b;
|
||||
--border-dropdown: #3d425e;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--danger: #f7768e;
|
||||
}
|
||||
|
||||
/* ─── Tokyo Night Storm ─── */
|
||||
[data-theme='tokyo-night-storm'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1f2335;
|
||||
--ctp-mantle: #1f2335;
|
||||
--ctp-base: #24283b;
|
||||
--ctp-surface0: #2f334d;
|
||||
--ctp-surface1: #3b3f5c;
|
||||
--ctp-surface2: #414868;
|
||||
--ctp-overlay0: #565f89;
|
||||
--ctp-overlay1: #6272a4;
|
||||
--ctp-overlay2: #737aa2;
|
||||
--ctp-text: #c0caf5;
|
||||
--ctp-subtext1: #a9b1d6;
|
||||
--ctp-subtext0: #9aa5ce;
|
||||
--ctp-mauve: #bb9af7;
|
||||
--ctp-lavender: #c0caf5;
|
||||
--ctp-pink: #f7768e;
|
||||
--ctp-flamingo: #ff9e64;
|
||||
--ctp-rosewater: #f7768e;
|
||||
--ctp-blue: #7aa2f7;
|
||||
--ctp-sapphire: #7dcfff;
|
||||
--ctp-sky: #7dcfff;
|
||||
--ctp-teal: #2ac3de;
|
||||
--ctp-green: #9ece6a;
|
||||
--ctp-yellow: #e0af68;
|
||||
--ctp-peach: #ff9e64;
|
||||
--ctp-maroon: #f7768e;
|
||||
--ctp-red: #f7768e;
|
||||
|
||||
--bg-app: #24283b;
|
||||
--bg-sidebar: #1f2335;
|
||||
--bg-card: #2f334d;
|
||||
--bg-hover: #3b3f5c;
|
||||
--bg-player: #1f2335;
|
||||
--bg-glass: rgba(36, 40, 59, 0.82);
|
||||
--accent: #7aa2f7;
|
||||
--accent-dim: rgba(122, 162, 247, 0.15);
|
||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary:#a9b1d6;
|
||||
--text-muted: #565f89;
|
||||
--border: #3b3f5c;
|
||||
--border-subtle: #2f334d;
|
||||
--border-dropdown: #414868;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--danger: #f7768e;
|
||||
}
|
||||
|
||||
/* ─── Tokyo Night Light ─── */
|
||||
[data-theme='tokyo-night-light'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23343b58%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #cbccd1;
|
||||
--ctp-mantle: #d5d6db;
|
||||
--ctp-base: #d5d6db;
|
||||
--ctp-surface0: #e9e9ec;
|
||||
--ctp-surface1: #c4c5cc;
|
||||
--ctp-surface2: #b4b5be;
|
||||
--ctp-overlay0: #a0a1ab;
|
||||
--ctp-overlay1: #9699a3;
|
||||
--ctp-overlay2: #8488a0;
|
||||
--ctp-text: #343b58;
|
||||
--ctp-subtext1: #565a6e;
|
||||
--ctp-subtext0: #6b6f85;
|
||||
--ctp-mauve: #5a4a78;
|
||||
--ctp-lavender: #34548a;
|
||||
--ctp-pink: #8f2f5c;
|
||||
--ctp-flamingo: #965027;
|
||||
--ctp-rosewater: #8c4351;
|
||||
--ctp-blue: #34548a;
|
||||
--ctp-sapphire: #0f4b6e;
|
||||
--ctp-sky: #0f4b6e;
|
||||
--ctp-teal: #0f4b6e;
|
||||
--ctp-green: #485e30;
|
||||
--ctp-yellow: #8f5e15;
|
||||
--ctp-peach: #965027;
|
||||
--ctp-maroon: #8c4351;
|
||||
--ctp-red: #8c4351;
|
||||
|
||||
--bg-app: #d5d6db;
|
||||
--bg-sidebar: #cbccd1;
|
||||
--bg-card: #e9e9ec;
|
||||
--bg-hover: #c4c5cc;
|
||||
--bg-player: #cbccd1;
|
||||
--bg-glass: rgba(213, 214, 219, 0.9);
|
||||
--accent: #34548a;
|
||||
--accent-dim: rgba(52, 84, 138, 0.15);
|
||||
--accent-glow: rgba(52, 84, 138, 0.25);
|
||||
--text-primary: #343b58;
|
||||
--text-secondary:#565a6e;
|
||||
--text-muted: #9699a3;
|
||||
--border: #c4c5cc;
|
||||
--border-subtle: #d5d6db;
|
||||
--positive: #485e30;
|
||||
--warning: #8f5e15;
|
||||
--danger: #8c4351;
|
||||
}
|
||||
|
||||
/* ─── Global Base Settings ─── */
|
||||
:root {
|
||||
/* Typography */
|
||||
@@ -470,6 +1251,8 @@
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
min-width: 1280px;
|
||||
min-height: 720px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -561,7 +1344,7 @@ body.is-dragging * {
|
||||
color: var(--ctp-crust);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--ctp-lavender); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
|
||||
.btn-primary:hover { background: var(--accent); filter: brightness(1.1); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user