mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af18aef42a | |||
| d3ffa30bf5 | |||
| f666f84479 | |||
| c91fdd7e1d | |||
| 9bdd433a4b | |||
| a5fd70d3eb | |||
| 744775d3a1 | |||
| 9b1f3b019f | |||
| baa701dd74 | |||
| 1e599d9636 |
@@ -14,9 +14,9 @@ jobs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: get version
|
||||
@@ -29,16 +29,27 @@ jobs:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
with:
|
||||
script: |
|
||||
const tag = `app-v${process.env.PACKAGE_VERSION}`;
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body: 'See the assets to download this version and install.',
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
return data.id
|
||||
});
|
||||
return data.id;
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
@@ -56,9 +67,9 @@ jobs:
|
||||
args: ''
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: install Rust stable
|
||||
@@ -80,13 +91,14 @@ jobs:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
|
||||
@@ -97,7 +109,7 @@ jobs:
|
||||
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
|
||||
+195
@@ -5,6 +5,201 @@ 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.4.1] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Albums — Performance & Memory
|
||||
- **Auto-refresh removed**: The 30-second auto-cycle timer caused 10 React state updates/second (progress bar interval) and a burst of 30 concurrent image fetches on every tick, eventually making the whole app unresponsive. The page now loads once on mount; use the manual refresh button to get a new selection.
|
||||
- **Concurrent fetch limit**: Image fetches are now capped at 5 simultaneous network requests (was unlimited — 30 at once on every refresh).
|
||||
- **Object URL memory leak**: The in-memory image cache now caps at 150 entries and revokes old object URLs via `URL.revokeObjectURL()` when evicting. Previously, object URLs accumulated without bound across the entire session.
|
||||
- **Dangling state updates**: `useCachedUrl` now uses a cancellation flag — if a component unmounts while a fetch is in flight (e.g. during a grid refresh), the resolved URL is discarded instead of calling `setState` on an unmounted component.
|
||||
|
||||
#### i18n
|
||||
- Page title "Neueste" on the New Releases page was hardcoded German. Now uses `t('sidebar.newReleases')`.
|
||||
|
||||
## [1.4.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
|
||||
#### Statistics Page — Upgraded
|
||||
- **Library overview**: Four stat cards at the top showing total Artists, Albums, Songs, and Genres — counts derived from the library in parallel.
|
||||
- **Recently Played**: Horizontal scroll row showing the last played albums with cover art.
|
||||
- **Most Played**: Ranked list of the most frequently played tracks.
|
||||
- **Highest Rated**: List of top-rated tracks by user star rating.
|
||||
- **Genre Chart**: Visual bar chart of the top genres by song and album count.
|
||||
|
||||
#### Playlists Page — Redesigned
|
||||
- Replaced the card grid with a clean list layout.
|
||||
- **Sort buttons**: Sort by Name, Tracks, or Duration — click again to toggle ascending/descending.
|
||||
- **Filter input**: Live search across playlist names.
|
||||
- Play and delete buttons appear on row hover.
|
||||
|
||||
#### Favorites — Songs Section Upgraded
|
||||
- Tracks now display in a full tracklist layout matching Album Detail: separate `#`, Title, Artist, and Duration columns with a header row.
|
||||
- Artist name is clickable and navigates to the artist page.
|
||||
- Right-click context menu on any track (Go to Album, Add to Queue, etc.).
|
||||
- **"Add all to queue"** button (`btn btn-surface`) next to the section title.
|
||||
|
||||
#### Context Menu — Go to Album
|
||||
- New **Go to Album** option (`Disc3` icon) added for `song` and `queue-item` context menu types.
|
||||
- Only shown when the song has a known `albumId`.
|
||||
|
||||
#### Queue Panel — Meta Box
|
||||
- Now shows: **Title** (no link) → **Artist** (linked to artist page) → **Album** (linked to album page).
|
||||
- Removed year display and the old title→album link.
|
||||
|
||||
#### Random Mix — Hover Persistence
|
||||
- Track row stays highlighted while its context menu is open via `.context-active` CSS class.
|
||||
- Highlight is cleared automatically when the context menu closes.
|
||||
|
||||
#### Artist Cards — Redesigned
|
||||
- `ArtistCardLocal` now matches `AlbumCard` exactly: no padding, full-width square cover via `aspect-ratio: 1`, name and meta below.
|
||||
- Uses `CachedImage` with `coverArtCacheKey` for proper IndexedDB caching.
|
||||
- Same `flex: 0 0 clamp(140px, 15vw, 180px)` sizing as album cards — artist cards are no longer oversized.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Random Albums — Cover Loading & Manual Refresh
|
||||
- **Removed `renderKey`**: The album grid was fully remounted on every refresh, restarting all 30 IndexedDB image lookups from scratch. Grid is now stable — only data changes, images stay cached.
|
||||
- **`loadingRef` guard**: Prevents concurrent fetch calls if the auto-cycle timer fires during a manual refresh.
|
||||
- **Timer race condition**: Manual refresh now calls `clearTimers()` before `load()`, eliminating the race where the auto-cycle timer fired mid-load.
|
||||
|
||||
#### Favorites — Artist Navigation
|
||||
- Arrow nav buttons in the Artists section now use the same CSS classes as the Albums section (`album-row-section`, `album-row-header`, `album-row-nav`) — consistent styling across both rows.
|
||||
|
||||
### Changed
|
||||
- **AlbumDetail** refactored into a thin orchestrator. Logic extracted into `AlbumHeader` (`src/components/AlbumHeader.tsx`) and `AlbumTrackList` (`src/components/AlbumTrackList.tsx`).
|
||||
- **German i18n**: "Queue" consistently translated as "Warteschlange" throughout — `queue.shuffle`, `favorites.enqueueAll`.
|
||||
|
||||
## [1.3.0] - 2026-03-15
|
||||
|
||||
### Added
|
||||
|
||||
#### Player Bar — Complete Redesign
|
||||
- **Waveform seekbar**: Replaces the classic thin slider. A canvas-based waveform with 500 deterministic bars (seeded by `trackId`) fills the full available width. Played portion renders as a blue → mauve gradient with a soft glow; buffered range is slightly brighter; unplayed bars are dimmed to 28% opacity. Click or drag anywhere to seek.
|
||||
- **New layout**: Single flex row — `[Cover + Track Info] [Transport Controls] [Waveform + Times] [Volume]`. More breathing room for the waveform; controls feel lighter and better proportioned.
|
||||
- **Queue toggle relocated**: Moved from the bottom player bar to the top-right of the content header — consistent with the sidebar collapse button pattern. Uses `PanelRightClose` / `PanelRight` icons (same family as `PanelLeftClose` / `PanelLeft` in the sidebar).
|
||||
|
||||
#### Ambient Stage — MilkDrop Visualizer
|
||||
- **Butterchurn integration**: Clicking the waveform icon (top-right of the fullscreen player) activates the MilkDrop visualizer powered by [butterchurn](https://github.com/jberg/butterchurn) + `butterchurn-presets`.
|
||||
- A hidden `<audio>` element is routed through the Web Audio API `AnalyserNode` (not connected to `AudioDestinationNode` — completely silent). The Rust/rodio engine continues to handle actual audio output.
|
||||
- Starts with a random preset; the shuffle button cycles through all available presets with a 2-second blend transition. Current preset name is shown in the top bar.
|
||||
- When the visualizer is active, the blurred background, orbs, and overlay are replaced by the canvas.
|
||||
|
||||
#### Tracklist — Animated Equalizer Indicator
|
||||
- The currently **playing** track shows three animated equalizer bars (CSS `scaleY` keyframe animation, staggered timing) instead of a static play icon.
|
||||
- When **paused**, the static play icon is shown.
|
||||
- Hovering any other track still shows a play icon.
|
||||
- Track row alignment fixed: `align-items: center` on the grid row + `.track-num` as flex center — icons and track numbers are now perfectly vertically aligned with the song title.
|
||||
|
||||
#### Artist Pages — In-App Browser
|
||||
- Last.fm and Wikipedia buttons now open a native **Tauri `WebviewWindow`** (1100 × 780, centered) instead of the system browser. Both sites load fully within the app and can be closed independently.
|
||||
- Required new capabilities: `core:window:allow-create`, `core:webview:allow-create-webview-window`.
|
||||
|
||||
#### Update Checker
|
||||
- Update check now runs **every 10 minutes** during runtime in addition to the initial check 1.5 s after launch.
|
||||
- Version label in the update toast no longer includes a `v` prefix (shows `1.3.0` instead of `v1.3.0`).
|
||||
|
||||
#### Help Page
|
||||
- New **Random Mix** section: explains the random mix, keyword filter, and super genre mix.
|
||||
- Updated **Playback** section: waveform seekbar, MilkDrop visualizer, queue shuffle.
|
||||
- Updated **Library** section: in-app browser for artist links.
|
||||
- Updated queue entry to reflect the new toggle location.
|
||||
- **Accordion styling**: open question and answer share a continuous 3 px accent stripe on the left; answer background uses `--bg-app` for clear contrast against the question's `--bg-card`.
|
||||
|
||||
### Fixed
|
||||
- **Version in Settings** was hardcoded to `1.0.12`. Now imported from `package.json` at build time — same source as the sidebar update checker.
|
||||
- **Hero / Discover duplicate albums**: Both sections previously fetched `random` independently, often showing the same albums. Now a single request fetches 20; `slice(0, 8)` goes to the Hero carousel and `slice(8)` to the Discover row.
|
||||
- **Active track pulse too aggressive**: Changed from a `background: transparent` flash to a gentle `opacity: 0.6` fade over 3 s — significantly less distracting.
|
||||
|
||||
### Changed
|
||||
- **Blacklist → Keyword Filter**: Renamed throughout UI and i18n (EN + DE) to better reflect that the filter matches genre, title, and album fields — not just genre tags.
|
||||
|
||||
## [1.2.0] - 2026-03-15
|
||||
|
||||
### Added
|
||||
|
||||
#### Rust Audio Engine (replaces Howler.js)
|
||||
- **New native audio backend** built in Rust using [rodio](https://github.com/RustAudio/rodio). Audio is now decoded and played entirely in the Tauri backend — no more reliance on the WebView's `<audio>` element or GStreamer pipeline quirks.
|
||||
- Tauri commands: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume`.
|
||||
- Frontend events: `audio:playing` (with duration), `audio:progress` (every 500 ms), `audio:ended`, `audio:error`.
|
||||
- Generation counter (`AtomicU64`) ensures stale downloads from skipped tracks are cancelled immediately and do not emit events.
|
||||
- Wall-clock position tracking (`seek_offset + elapsed`) instead of `sink.empty()` (unreliable in rodio 0.19 for VBR MP3). `audio:ended` fires after two consecutive ticks within 1 second of the track end — avoids false positives near the end without adding latency.
|
||||
- Seek via `sink.try_seek()` — no pause/play cycle, no spurious `ended` events.
|
||||
- Volume clamped to `[0.0, 1.0]` on every call.
|
||||
|
||||
#### Playback Persistence & Cold-Start Resume
|
||||
- `currentTrack`, `queue`, `queueIndex`, and `currentTime` are now persisted to `localStorage` via Zustand `partialize`.
|
||||
- On app restart with a previously loaded track, clicking Play resumes from the saved position without losing the queue.
|
||||
- Position priority: server play queue position (if > 0) takes precedence over the locally saved value, so cross-device resume works correctly.
|
||||
|
||||
#### Random Mix — Genre Filter & Blacklist
|
||||
- **Exclude audiobooks & radio plays** toggle: filters out songs whose genre, title, or album match a hardcoded list (`Hörbuch`, `Hörspiel`, `Audiobook`, `Spoken Word`, `Podcast`, `Krimi`, `Thriller`, `Speech`, `Fantasy`, `Comedy`, `Literature`, and more).
|
||||
- **Custom genre blacklist**: add any genre keyword via the collapsible chip panel on the Random Mix page or in Settings → Random Mix. Persisted across sessions.
|
||||
- **Clickable genre chips** in the tracklist: clicking an unblocked genre tag adds it to the blacklist instantly with 1.5 s visual feedback. Blocked genres are shown in red.
|
||||
- Blacklist filter checks `song.genre`, `song.title`, and `song.album` to catch mislabelled tracks.
|
||||
|
||||
#### Random Mix — Super Genre Mix
|
||||
- Nine pre-defined **Super Genres** (Metal, Rock, Pop, Electronic, Jazz, Classical, Hip-Hop, Country, World) appear as buttons, auto-generated from the server's genre list — only genres with at least one matching keyword are shown.
|
||||
- Selecting a Super Genre fetches up to 50 songs distributed across all matched sub-genres in parallel, then shuffles the result.
|
||||
- **Progressive rendering**: the tracklist appears as soon as the first genre request returns — users with large Metal/Rock libraries no longer stare at a spinner for the entire fetch. A small inline spinner next to the title indicates that more genres are still loading.
|
||||
- **"Load 10 more"** button: fetches 10 additional songs from the same matched genres and appends them to the play queue.
|
||||
- Random playlist is automatically hidden while a Genre Mix is active.
|
||||
- Fetch timeout raised to **45 seconds** per genre request (was 15 s) and `Promise.allSettled` used so a single slow/failing genre does not abort the entire mix.
|
||||
|
||||
#### Queue Panel
|
||||
- **Shuffle button** in the queue header: Fisher-Yates shuffles all queued tracks while keeping the currently playing track at position 0. Button is disabled when the queue has fewer than 2 entries.
|
||||
|
||||
#### UI / UX
|
||||
- **LiveSearch keyboard navigation**: arrow keys navigate the dropdown, Enter selects the highlighted item or navigates to the full search results page, Escape closes the dropdown.
|
||||
- **Multi-line tooltip support**: add `data-tooltip-wrap` attribute to any element with `data-tooltip` to enable line-wrapping (uses `white-space: pre-line` + `\n` in the string). Respects a 220 px max-width.
|
||||
- **Genre column info icon** in Random Mix tracklist header: hover tooltip explains the clickable-genre-to-blacklist feature.
|
||||
- **Update link** in the sidebar now uses Tauri Shell plugin `open()` to launch the system browser correctly — `<a target="_blank">` has no effect inside a Tauri WebView.
|
||||
|
||||
### Fixed
|
||||
- **Songs skipping immediately** (root cause: Tauri v2 IPC maps Rust `snake_case` parameters to **camelCase** on the JS side — `duration_hint` must be `durationHint`). All `invoke()` calls updated.
|
||||
- **Play button doing nothing after restart**: `currentTrack` was `null` after restart (not persisted). Fixed by adding it to `partialize`.
|
||||
- **Position not restored after restart**: `initializeFromServerQueue` overwrote the local saved position with the server value even when the server reported 0. Now falls back to the localStorage value when the server position is 0.
|
||||
- **Genre Mix blank on Metal/Rock**: a single timed-out genre request caused `Promise.all` to reject the entire mix. Replaced with `Promise.allSettled` + 45 s timeout; partial results are shown immediately.
|
||||
- **Tooltip z-index**: tooltips in the main content area were rendered behind the queue panel. Fixed by giving `.main-content` `z-index: 1`, establishing a stacking context above the queue (which sits later in DOM order).
|
||||
- **Sidebar title clipping**: "Psysonic" brand text was truncated at narrow viewport widths. Minimum sidebar width raised from 180 px to 200 px.
|
||||
|
||||
### Changed
|
||||
- **Audio architecture**: Howler.js removed. All audio state (`isPlaying`, `isAudioPaused`, `currentTime`, `duration`) is now driven by Tauri events from the Rust engine rather than Howler callbacks.
|
||||
- **Random Mix layout**: Filter/blacklist panel and Genre Mix buttons are now combined in a two-column card at the top of the page instead of being scattered across the page.
|
||||
- **Hardcoded genre blacklist** extended with: `Fantasy`, `Comedy`, `Literature`.
|
||||
- **`getRandomSongs`** now accepts an optional `timeout` parameter (default 15 s) so callers can pass a longer value for large-library scenarios.
|
||||
|
||||
## [1.0.12] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **Seek Stop Bug**: Clicking the progress bar a second time no longer stops playback. Root cause: WebKit and GStreamer fire spurious `ended` events immediately after a direct `audioNode.currentTime` seek. A guard now checks `lastSeekAt` + playhead position to silently discard these false alarms.
|
||||
- **Play/Pause Hang**: Rapidly double-clicking the play/pause button no longer freezes the audio pipeline. A 300 ms lock prevents a second toggle from issuing `pause→play` before GStreamer has finished the previous state transition.
|
||||
- **Queue DnD (macOS / Windows)**: Drop target index is now calculated from the mouse `clientY` position at drop time instead of refs, eliminating the `dragend`-before-`drop` timing race on macOS WKWebView and Windows WebView2.
|
||||
|
||||
### Added
|
||||
- **Live Now Playing navigation**: Clicking an entry in the Live dropdown now navigates to the corresponding album page.
|
||||
|
||||
### Changed
|
||||
- **Hero blur**: Increased background blur in the Hero section for a more immersive look.
|
||||
|
||||
## [1.0.11] - 2026-03-14
|
||||
|
||||
### Added
|
||||
- **Search Results Page**: Pressing Enter in the search bar now navigates to a dedicated full search results page showing artists, albums, and songs with proper column layout and headers.
|
||||
|
||||
### Fixed
|
||||
- **Search Results Column Alignment**: Artist and album columns in the search results song list are now correctly aligned with their column headers.
|
||||
- **Search Results Header Alignment**: Fixed column header labels not aligning with song row content (root cause: `auto`-width Format column was sized independently per grid row).
|
||||
|
||||
### Changed
|
||||
- **Gapless Playback removed**: Removed the experimental gapless playback feature. It caused intermittent song skipping and beginning cutoffs and was not reliable enough to ship. Standard sequential playback is used instead.
|
||||
|
||||
### Known Issues
|
||||
- ~~**Seeking**: Seeking may occasionally be unreliable, particularly on Linux/GStreamer.~~ Fixed in 1.0.12.
|
||||
- ~~**Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop may not always work correctly on macOS and Windows.~~ Fixed in 1.0.12.
|
||||
|
||||
## [1.0.10] - 2026-03-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2026 Psychotoxical
|
||||
Copyright (C) 2026 Psychotoxical
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The full license text is available at:
|
||||
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -22,15 +22,19 @@ 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.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
|
||||
- ⚡ **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.
|
||||
- 📻 **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.
|
||||
- 💾 **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.
|
||||
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
|
||||
- 🔄 **Update Notifications**: Built-in update checker that notifies you when a new version is available on GitHub.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🔗 **In-App Browser**: Last.fm and Wikipedia links on artist pages open in a dedicated native window — no need to leave the app.
|
||||
- 🔄 **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).
|
||||
|
||||
## ● Known Limitations
|
||||
@@ -90,4 +94,6 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
|
||||
## 📄 License
|
||||
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
|
||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
||||
|
||||
Generated
+61
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "0.1.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "0.1.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -16,7 +16,8 @@
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"axios": "^1.7.7",
|
||||
"howler": "^2.2.4",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -28,7 +29,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
@@ -1574,13 +1574,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/howler": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz",
|
||||
"integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/md5": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz",
|
||||
@@ -1664,6 +1657,16 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1711,6 +1714,27 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
|
||||
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn-presets": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
|
||||
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2",
|
||||
"lodash": "^4.17.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1773,6 +1797,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
@@ -1830,6 +1862,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecma-proposal-math-extensions": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
|
||||
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
@@ -2110,12 +2148,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/howler": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
|
||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -2194,6 +2226,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2448,6 +2486,12 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.0.10",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,7 +19,8 @@
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"axios": "^1.7.7",
|
||||
"howler": "^2.2.4",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -31,7 +32,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
|
||||
Generated
+840
-22
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.0.10"
|
||||
version = "1.4.1"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -30,3 +30,6 @@ tauri-plugin-dialog = "2"
|
||||
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"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen"
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -0,0 +1,322 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::{Decoder, Sink, Source};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
// ─── Debug logger ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Engine state (registered as Tauri managed state) ────────────────────────
|
||||
|
||||
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.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
pub http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
pub fn position(&self) -> f64 {
|
||||
if let Some(p) = self.paused_at {
|
||||
return p;
|
||||
}
|
||||
if let Some(t) = self.play_started {
|
||||
let elapsed = t.elapsed().as_secs_f64();
|
||||
(self.seek_offset + elapsed).min(self.duration_secs.max(0.001))
|
||||
} else {
|
||||
self.seek_offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
.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}");
|
||||
}
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
let stream_handle = rx.recv().expect("audio stream handle");
|
||||
|
||||
let engine = AudioEngine {
|
||||
stream_handle: Arc::new(stream_handle),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
}
|
||||
|
||||
// ─── Event payloads ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ProgressPayload {
|
||||
pub current_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
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())?;
|
||||
|
||||
// Bail if superseded while downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ────────────────────────────────────────────────────────────────
|
||||
let data: Vec<u8> = bytes.into();
|
||||
|
||||
// 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
|
||||
} else {
|
||||
decoder_duration.unwrap_or(duration_hint)
|
||||
};
|
||||
|
||||
let cursor = Cursor::new(data);
|
||||
let decoder = Decoder::new(cursor).map_err(|e| {
|
||||
app.emit("audio:error", e.to_string()).ok();
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// 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));
|
||||
sink.append(decoder);
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if is_paused {
|
||||
// Don't advance near-end counter while paused (stay put).
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if dur > 1.0 && pos >= dur - 1.0 {
|
||||
near_end_ticks += 1;
|
||||
if near_end_ticks >= 2 {
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
app_clone.emit("audio:ended", ()).ok();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
near_end_ticks = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() {
|
||||
let pos = cur.position();
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_resume(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() {
|
||||
sink.stop();
|
||||
}
|
||||
cur.duration_secs = 0.0;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
||||
.map_err(|e: rodio::source::SeekError| e.to_string())?;
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seconds);
|
||||
} else {
|
||||
cur.seek_offset = seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -1,6 +1,8 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
@@ -18,7 +20,10 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -107,7 +112,16 @@ pub fn run() {
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![greet, exit_app])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
audio::audio_play,
|
||||
audio::audio_pause,
|
||||
audio::audio_resume,
|
||||
audio::audio_stop,
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.0.10",
|
||||
"version": "1.4.1",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -22,7 +22,8 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true
|
||||
"visible": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+19
-2
@@ -3,6 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { PanelRight, PanelRightClose } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
@@ -23,10 +25,11 @@ import Statistics from './pages/Statistics';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
@@ -36,9 +39,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -109,7 +114,7 @@ function AppShell() {
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(180px, 15vw, 220px)',
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
@@ -123,6 +128,13 @@ function AppShell() {
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleQueue}
|
||||
title={t('player.toggleQueue')}
|
||||
>
|
||||
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
|
||||
</button>
|
||||
</header>
|
||||
<div className="content-body" style={{ padding: 0 }}>
|
||||
<Routes>
|
||||
@@ -137,6 +149,7 @@ function AppShell() {
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
@@ -221,6 +234,10 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
|
||||
+6
-2
@@ -74,6 +74,8 @@ export interface SubsonicSong {
|
||||
samplingRate?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
@@ -160,8 +162,10 @@ export async function getAlbumList(
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size };
|
||||
if (genre) params.genre = genre;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import React 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 CachedImage from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (
|
||||
name.startsWith('on') ||
|
||||
(name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
|
||||
(name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
coverArt?: string;
|
||||
recordLabel?: string;
|
||||
}
|
||||
|
||||
interface AlbumHeaderProps {
|
||||
info: AlbumInfo;
|
||||
songs: SubsonicSong[];
|
||||
coverUrl: string;
|
||||
coverKey: string;
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
onToggleStar: () => void;
|
||||
onDownload: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
info,
|
||||
songs,
|
||||
coverUrl,
|
||||
coverKey,
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
bio,
|
||||
bioOpen,
|
||||
onToggleStar,
|
||||
onDownload,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span className="album-info-dot">·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
<span className="download-hint">
|
||||
<Info size={12} />
|
||||
{t('albumDetail.downloadHintShort')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
hasVariousArtists: boolean;
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
hoveredSongId: string | null;
|
||||
setHoveredSongId: (id: string | null) => void;
|
||||
ratings: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||
}
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
hasVariousArtists,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
hoveredSongId,
|
||||
setHoveredSongId,
|
||||
ratings,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const makeTrack = (song: SubsonicSong): Track => ({
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
|
||||
coverArt: song.coverArt, track: song.track, year: song.year,
|
||||
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="tracklist">
|
||||
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackRating')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
</div>
|
||||
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span>
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="track-num"
|
||||
style={{
|
||||
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
|
||||
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => onPlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -12,16 +13,14 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
@@ -31,14 +30,14 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-name" data-tooltip={artist.name}>
|
||||
{artist.name}
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<div className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
|
||||
if (artists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="artist-row-section">
|
||||
<div className="artist-row-header">
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="artist-row-nav">
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
|
||||
@@ -10,7 +10,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
|
||||
let cancelled = false;
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -146,6 +146,11 @@ export default function ContextMenu() {
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
@@ -205,6 +210,11 @@ export default function ContextMenu() {
|
||||
{t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VisualizerCanvas from './VisualizerCanvas';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -147,6 +148,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
|
||||
const [vizActive, setVizActive] = useState(false);
|
||||
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
@@ -177,20 +182,59 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
|
||||
{/* Layer 1 — blurred artist image (falls back to cover art) */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Layer 2 — drifting color orbs */}
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
{/* Layer 1 — blurred artist image OR visualizer */}
|
||||
{vizActive && currentTrack ? (
|
||||
<VisualizerCanvas
|
||||
trackId={currentTrack.id}
|
||||
nextPresetTrigger={nextPresetTrigger}
|
||||
onPresetName={setPresetName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Visualizer toggle + preset controls */}
|
||||
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{vizActive && (
|
||||
<>
|
||||
{presetName && (
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{presetName}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => setNextPresetTrigger(n => n + 1)}
|
||||
aria-label={t('player.nextPreset')}
|
||||
data-tooltip={t('player.nextPreset')}
|
||||
style={{ color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
|
||||
onClick={() => setVizActive(v => !v)}
|
||||
aria-label={t('player.visualizer')}
|
||||
data-tooltip={t('player.visualizer')}
|
||||
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
<AudioWaveform size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
|
||||
|
||||
@@ -42,7 +42,11 @@ function HeroBg({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
interface HeroProps {
|
||||
albums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -50,8 +54,9 @@ export default function Hero() {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, []);
|
||||
}, [albumsProp]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
|
||||
+105
-67
@@ -19,9 +19,11 @@ export default function LiveSearch() {
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
@@ -38,7 +40,7 @@ export default function LiveSearch() {
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
@@ -51,6 +53,40 @@ export default function LiveSearch() {
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
|
||||
// Flat list of all navigable items for keyboard nav
|
||||
const flatItems = results ? [
|
||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
|
||||
setOpen(false); setQuery('');
|
||||
}}))),
|
||||
] : [];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || !flatItems.length) {
|
||||
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = Math.min(activeIndex + 1, flatItems.length - 1);
|
||||
setActiveIndex(next);
|
||||
dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = Math.max(activeIndex - 1, -1);
|
||||
setActiveIndex(next);
|
||||
if (next >= 0) dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); }
|
||||
else if (query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false); setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="live-search" ref={ref} role="search">
|
||||
<div className="live-search-input-wrap">
|
||||
@@ -69,6 +105,7 @@ export default function LiveSearch() {
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => results && setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
@@ -82,78 +119,79 @@ export default function LiveSearch() {
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="live-search-dropdown" id="search-results" role="listbox">
|
||||
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
|
||||
{!hasResults && !loading && (
|
||||
<div className="search-empty">{t('search.noResults', { query })}</div>
|
||||
)}
|
||||
|
||||
{results?.artists.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
||||
{results.artists.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
let idx = 0;
|
||||
return <>
|
||||
{results?.artists.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
||||
{results.artists.map(a => {
|
||||
const i = idx++;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.albums.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
||||
{results.albums.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
>
|
||||
{a.coverArt ? (
|
||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
<div className="search-result-sub">{a.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{results?.albums.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
||||
{results.albums.map(a => {
|
||||
const i = idx++;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
{a.coverArt ? (
|
||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
<div className="search-result-sub">{a.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.songs.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
||||
{results.songs.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
className="search-result-item"
|
||||
onClick={() => {
|
||||
playTrack({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
|
||||
});
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
role="option"
|
||||
>
|
||||
<div className="search-result-icon"><Music size={14} /></div>
|
||||
<div>
|
||||
<div className="search-result-name">{s.title}</div>
|
||||
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{results?.songs.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
||||
{results.songs.map(s => {
|
||||
const i = idx++;
|
||||
return (
|
||||
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Music size={14} /></div>
|
||||
<div>
|
||||
<div className="search-result-name">{s.title}</div>
|
||||
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,11 @@ import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subs
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function NowPlayingDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -121,7 +123,11 @@ export default function NowPlayingDropdown() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{visible.map((stream, idx) => (
|
||||
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}>
|
||||
<div
|
||||
key={`${stream.id}-${idx}`}
|
||||
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
|
||||
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.coverArt ? (
|
||||
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -18,32 +20,27 @@ function formatTime(seconds: number): string {
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { currentTrack, isPlaying, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
} = usePlayerStore();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seek(parseFloat(e.target.value));
|
||||
}, [seek]);
|
||||
|
||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [setVolume]);
|
||||
|
||||
const pct = progress * 100;
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
const progressStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${pct}%, var(--ctp-overlay0) ${pct}%, var(--ctp-overlay0) ${buf}%, var(--ctp-surface2) ${buf}%)`,
|
||||
};
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="player-track-info">
|
||||
<div
|
||||
@@ -89,88 +86,69 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls + Progress */}
|
||||
<div className="player-controls">
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={16} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : 'inherit' }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-progress">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-progress-bar">
|
||||
<input
|
||||
type="range"
|
||||
id="player-seek"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={progressStyle}
|
||||
aria-label={t('player.progress')}
|
||||
/>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume + Connection */}
|
||||
<div className="player-right">
|
||||
<div className="volume-control" aria-label={t('player.volume')}>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleQueue}
|
||||
aria-label={t('player.toggleQueue')}
|
||||
data-tooltip={t('player.toggleQueue')}
|
||||
style={{ marginLeft: 'var(--space-3)', color: isQueueVisible ? 'var(--accent)' : 'var(--text-secondary)' }}
|
||||
{/* Transport Controls */}
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
<List size={18} />
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Waveform Seekbar */}
|
||||
<div className="player-waveform-section">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
|
||||
aria-label={t('player.volume')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
className="player-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -110,6 +110,10 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
|
||||
);
|
||||
}
|
||||
|
||||
// Module-level fallback for fromIdx — survives the dragend-before-drop race on
|
||||
// macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2.
|
||||
let _dragFromIdx: number | null = null;
|
||||
|
||||
export default function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -122,6 +126,7 @@ export default function QueuePanel() {
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
@@ -132,6 +137,8 @@ export default function QueuePanel() {
|
||||
const draggedIdxRef = useRef<number | null>(null);
|
||||
const dragOverIdxRef = useRef<number | null>(null);
|
||||
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
@@ -151,10 +158,9 @@ export default function QueuePanel() {
|
||||
const onDragStart = (e: React.DragEvent, index: number) => {
|
||||
isDraggingInternalRef.current = true;
|
||||
draggedIdxRef.current = index;
|
||||
_dragFromIdx = index;
|
||||
setDraggedIdx(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Store index in dataTransfer too — on macOS WKWebView dragend fires before
|
||||
// drop, so the ref will already be null; dataTransfer survives that race.
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
|
||||
};
|
||||
|
||||
@@ -171,51 +177,58 @@ export default function QueuePanel() {
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reset visual state immediately.
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
// Delay clearing refs so onDropQueue can read them first.
|
||||
// On macOS WKWebView and Windows WebView2, dragend fires before drop
|
||||
// (spec violation), so synchronously clearing refs here loses the
|
||||
// drag source/destination indices before onDropQueue runs.
|
||||
setTimeout(() => {
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
}, 200);
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
// _dragFromIdx intentionally NOT cleared here — drop fires after dragend on
|
||||
// macOS WKWebView, so we need the value to survive into onDropQueue.
|
||||
// It is cleared in onDropQueue after use instead.
|
||||
};
|
||||
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Refs are still valid here — onDragEnd delays clearing them so they survive
|
||||
// the macOS/WebView2 dragend-before-drop race condition.
|
||||
const fromIdx = draggedIdxRef.current;
|
||||
const toIdx = dragOverIdxRef.current ?? queue.length;
|
||||
|
||||
// Cancel the pending timeout cleanup and clear refs immediately.
|
||||
// Clear visual state immediately
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
|
||||
// Read dataTransfer — set during dragstart, survives dragend on all platforms.
|
||||
// Used as fallback for fromIdx in case refs somehow weren't set.
|
||||
let parsedData: any = null;
|
||||
try {
|
||||
const raw = e.dataTransfer.getData('text/plain');
|
||||
if (raw) parsedData = JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Internal reorder: refs are the primary source; dataTransfer is the fallback.
|
||||
const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null);
|
||||
if (reorderFrom !== null) {
|
||||
if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx);
|
||||
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
|
||||
// fromIdx: prefer dataTransfer value; fall back to module-level var for
|
||||
// Windows WebView2 where getData() can return '' in the drop handler.
|
||||
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
|
||||
_dragFromIdx = null;
|
||||
|
||||
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
|
||||
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
|
||||
let toIdx = queue.length;
|
||||
if (queueListRef.current) {
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const rect = items[i].getBoundingClientRect();
|
||||
if (e.clientY < rect.top + rect.height / 2) {
|
||||
toIdx = parseInt(items[i].dataset.queueIdx!);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
// External drop (song / album dragged from elsewhere in the app)
|
||||
_dragFromIdx = null;
|
||||
if (!parsedData) return;
|
||||
if (parsedData.type === 'song') {
|
||||
enqueue([parsedData.track]);
|
||||
@@ -244,6 +257,9 @@ export default function QueuePanel() {
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
|
||||
<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>
|
||||
@@ -270,18 +286,18 @@ export default function QueuePanel() {
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3
|
||||
className="truncate"
|
||||
data-tooltip={currentTrack.title}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.title}</h3>
|
||||
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
|
||||
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.artist}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>{currentTrack.artist}</div>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.album}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
@@ -300,7 +316,7 @@ export default function QueuePanel() {
|
||||
|
||||
{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">
|
||||
<div className="queue-list" ref={queueListRef}>
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">
|
||||
{t('queue.emptyQueue')}
|
||||
@@ -324,8 +340,9 @@ export default function QueuePanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={(e) => {
|
||||
|
||||
+14
-10
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -23,7 +24,7 @@ const navItems = [
|
||||
];
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
|
||||
const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
const [lMaj, lMin, lPat] = parse(latest);
|
||||
const [cMaj, cMin, cPat] = parse(current);
|
||||
if (lMaj !== cMaj) return lMaj > cMaj;
|
||||
@@ -49,14 +50,12 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
|
||||
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
|
||||
</div>
|
||||
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
|
||||
<a
|
||||
<button
|
||||
className="update-toast-link"
|
||||
href="https://github.com/Psychotoxical/psysonic/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
|
||||
>
|
||||
{t('sidebar.updateLink')}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,20 +72,25 @@ export default function Sidebar({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, appVersion)) {
|
||||
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
|
||||
setLatestVersion(tag.replace(/^v/i, ''));
|
||||
}
|
||||
} catch {
|
||||
// network unavailable — silently skip
|
||||
}
|
||||
}, 1500);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
};
|
||||
|
||||
const initial = setTimeout(check, 1500);
|
||||
const interval = setInterval(check, 10 * 60 * 1000); // every 10 minutes
|
||||
|
||||
return () => { cancelled = true; clearTimeout(initial); clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import butterchurn from 'butterchurn';
|
||||
import butterchurnPresets from 'butterchurn-presets';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
interface Props {
|
||||
trackId: string;
|
||||
nextPresetTrigger: number;
|
||||
onPresetName: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const presetNamesRef = useRef<string[]>([]);
|
||||
const presetMapRef = useRef<Record<string, unknown>>({});
|
||||
const presetIdxRef = useRef(0);
|
||||
|
||||
// ── Init audio analysis + butterchurn ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Hidden audio element — routed through Web Audio for analysis only.
|
||||
// NOT connected to AudioDestinationNode → completely silent.
|
||||
// The Rust/rodio engine plays the actual audio.
|
||||
const streamUrl = buildStreamUrl(trackId);
|
||||
const audio = new Audio(streamUrl);
|
||||
audio.crossOrigin = 'anonymous';
|
||||
audioRef.current = audio;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
audioCtxRef.current = ctx;
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
// Intentionally no: source.connect(ctx.destination)
|
||||
|
||||
// Size canvas to fill its container
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || window.innerWidth;
|
||||
const h = rect.height || window.innerHeight;
|
||||
canvas.width = w * (window.devicePixelRatio || 1);
|
||||
canvas.height = h * (window.devicePixelRatio || 1);
|
||||
|
||||
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
pixelRatio: window.devicePixelRatio || 1,
|
||||
});
|
||||
vizRef.current = visualizer;
|
||||
visualizer.connectAudio(source);
|
||||
|
||||
// Presets
|
||||
const presets = butterchurnPresets.getPresets();
|
||||
const names = Object.keys(presets);
|
||||
presetNamesRef.current = names;
|
||||
presetMapRef.current = presets;
|
||||
const startIdx = Math.floor(Math.random() * names.length);
|
||||
presetIdxRef.current = startIdx;
|
||||
visualizer.loadPreset(presets[names[startIdx]], 2.0);
|
||||
onPresetName(names[startIdx]);
|
||||
|
||||
// Sync position + play state with main player
|
||||
const { currentTime, isPlaying } = usePlayerStore.getState();
|
||||
audio.currentTime = currentTime;
|
||||
if (isPlaying) audio.play().catch(() => {});
|
||||
|
||||
// Render loop
|
||||
const render = () => {
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
visualizer.render();
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
|
||||
// Keep canvas sized to window
|
||||
const onResize = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = r.width * dpr;
|
||||
canvas.height = r.height * dpr;
|
||||
visualizer.setRendererSize(canvas.width, canvas.height);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Sync play/pause with main player
|
||||
const unsubscribe = usePlayerStore.subscribe(state => {
|
||||
const a = audioRef.current;
|
||||
const c = audioCtxRef.current;
|
||||
if (!a || !c) return;
|
||||
if (state.isPlaying) {
|
||||
c.resume();
|
||||
a.play().catch(() => {});
|
||||
} else {
|
||||
a.pause();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', onResize);
|
||||
unsubscribe();
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
ctx.close();
|
||||
vizRef.current = null;
|
||||
};
|
||||
}, [trackId]); // re-init on track change
|
||||
|
||||
// ── Next preset ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!nextPresetTrigger) return;
|
||||
const viz = vizRef.current;
|
||||
const names = presetNamesRef.current;
|
||||
if (!viz || names.length === 0) return;
|
||||
const next = (presetIdxRef.current + 1) % names.length;
|
||||
presetIdxRef.current = next;
|
||||
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
|
||||
onPresetName(names[next]);
|
||||
}, [nextPresetTrigger]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
|
||||
function hashStr(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h ^ str.charCodeAt(i)) >>> 0;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function makeHeights(trackId: string): Float32Array {
|
||||
let s = hashStr(trackId);
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
h[i] = s / 0xffffffff;
|
||||
}
|
||||
// Smooth for an organic look
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||
}
|
||||
}
|
||||
// Normalize to [0.12, 1.0]
|
||||
let max = 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||
if (max > 0) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
|
||||
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
|
||||
// Pass 1 — unplayed (dim)
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 2 — buffered (slightly brighter)
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = colorBuffered;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 3 — played (gradient + 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.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trackId: string | undefined;
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
|
||||
useEffect(() => {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const seekFromX = (clientX: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !trackId) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const up = () => { isDragging.current = false; };
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => window.removeEventListener('mouseup', up);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
|
||||
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
|
||||
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+124
-6
@@ -44,6 +44,9 @@ const enTranslation = {
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Clear search',
|
||||
title: 'Search',
|
||||
resultsFor: 'Results for "{{query}}"',
|
||||
album: 'Album',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Who is listening?',
|
||||
@@ -124,6 +127,7 @@ const enTranslation = {
|
||||
artists: 'Artists',
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Add all to queue',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
@@ -142,6 +146,19 @@ const enTranslation = {
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
play: 'Play',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Exclude audiobooks & radio plays',
|
||||
excludeAudiobooksDesc: 'Matches keywords against genre, title, and album — e.g. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Keyword blocked',
|
||||
genreAddedToBlacklist: 'Added to filter list',
|
||||
genreAlreadyBlocked: 'Already blocked',
|
||||
blacklistToggle: 'Keyword Filter',
|
||||
genreMixTitle: 'Genre Mix',
|
||||
genreMixDesc: 'Select a genre to get a curated random mix',
|
||||
genreMixLoadMore: 'Load 10 more',
|
||||
genreMixNoGenres: 'No genres found on server.',
|
||||
filterPanelTitle: 'Filters',
|
||||
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title & album.',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
@@ -153,6 +170,11 @@ const enTranslation = {
|
||||
minutes: 'min.',
|
||||
track_one: '{{count}} Track',
|
||||
track_other: '{{count}} Tracks',
|
||||
filterPlaceholder: 'Filter playlists…',
|
||||
colName: 'Name',
|
||||
colTracks: 'Tracks',
|
||||
colDuration: 'Duration',
|
||||
noResults: 'No playlists match your filter.',
|
||||
},
|
||||
albums: {
|
||||
title: 'All Albums',
|
||||
@@ -258,8 +280,15 @@ const enTranslation = {
|
||||
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic'
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
randomMixTitle: 'Random Mix',
|
||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
|
||||
randomMixBlacklistPlaceholder: 'Add keyword…',
|
||||
randomMixBlacklistAdd: 'Add',
|
||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
@@ -276,7 +305,7 @@ const enTranslation = {
|
||||
q5: 'What keyboard shortcuts are available?',
|
||||
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
|
||||
q6: 'What is the queue?',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the list icon in the player bar. You can reorder tracks by dragging and save the current queue as a playlist.',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the panel icon in the top-right header (next to the Now Playing indicator). You can reorder tracks by dragging, shuffle with the shuffle button, and save the queue as a playlist.',
|
||||
q7: 'How do I open the fullscreen player?',
|
||||
a7: 'Click the album art thumbnail in the player bar at the bottom, or the expand icon next to it. Press Escape to close it again.',
|
||||
q8: 'How does repeat work?',
|
||||
@@ -302,6 +331,21 @@ const enTranslation = {
|
||||
a16: 'Psysonic uses your Navidrome server\'s built-in Last.fm integration. First, connect your Last.fm account in Navidrome\'s web interface. Then enable scrobbling in Psysonic\'s Settings.',
|
||||
q17: 'When is a scrobble sent?',
|
||||
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
|
||||
q22: 'What is the waveform in the player bar?',
|
||||
a22: 'The waveform seekbar replaces the classic progress slider. Click anywhere on it to jump to that position, or drag to scrub. The played portion glows with a blue-to-mauve gradient, the buffered range appears slightly brighter, and the unplayed portion is faded.',
|
||||
q23: 'How do I use the MilkDrop visualizer?',
|
||||
a23: 'Open the fullscreen player (click the album art in the player bar), then click the waveform icon in the top-right corner. The visualizer starts with a random MilkDrop preset. Use the shuffle button next to it to cycle through hundreds of presets.',
|
||||
q24: 'Can I shuffle the queue?',
|
||||
a24: 'Yes. Open the queue panel and click the shuffle icon in the queue header. The currently playing track stays at position 1 — all remaining tracks are randomly reordered.',
|
||||
q25: 'Do Last.fm and Wikipedia links on artist pages open in a browser?',
|
||||
a25: 'No — they open in a dedicated in-app window so you never leave Psysonic. The window shows the full website and can be closed independently.',
|
||||
s7: 'Random Mix',
|
||||
q26: 'What is Random Mix?',
|
||||
a26: 'Random Mix builds a playlist of random tracks from your entire library. Open it via "Random Mix" in the sidebar and click "New Mix". You can adjust the track count before generating.',
|
||||
q27: 'What is the Keyword Filter?',
|
||||
a27: 'The Keyword Filter excludes tracks whose genre, title, or album name contains specific words. Audiobooks are filtered automatically. Add custom keywords in Settings → Random Mix, or click any genre tag in the track list to add it instantly.',
|
||||
q28: 'What is the Super Genre Mix?',
|
||||
a28: 'The Super Genre Mix groups your library into broad categories (Rock, Metal, Electronic, Jazz, Classical, etc.) and builds a focused mix from that style. Select a category chip below the track list. Results appear progressively as each sub-genre is fetched.',
|
||||
s6: 'Troubleshooting',
|
||||
q18: 'Cover art and artist images load slowly.',
|
||||
a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.',
|
||||
@@ -325,6 +369,7 @@ const enTranslation = {
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete playlist "{{name}}"?',
|
||||
clear: 'Clear',
|
||||
shuffle: 'Shuffle queue',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
@@ -332,16 +377,30 @@ const enTranslation = {
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
mostPlayed: 'Most Played Albums',
|
||||
highestRated: 'Highest Rated Albums',
|
||||
genreDistribution: 'Genre Distribution (Top 20)',
|
||||
loadMore: 'Load more',
|
||||
statArtists: 'Artists',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Albums',
|
||||
recentlyAdded: 'Recently Added',
|
||||
decadeDistribution: 'Albums by Decade',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Albums',
|
||||
decadeUnknown: 'Unknown',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
openFullscreen: 'Open Fullscreen Player',
|
||||
fullscreen: 'Fullscreen Player',
|
||||
closeFullscreen: 'Close Fullscreen',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Next preset',
|
||||
closeTooltip: 'Close (Esc)',
|
||||
noTitle: 'No Title',
|
||||
stop: 'Stop',
|
||||
@@ -402,6 +461,9 @@ const deTranslation = {
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Suche leeren',
|
||||
title: 'Suche',
|
||||
resultsFor: 'Ergebnisse für „{{query}}"',
|
||||
album: 'Album',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Wer hört was?',
|
||||
@@ -482,6 +544,7 @@ const deTranslation = {
|
||||
artists: 'Künstler',
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
enqueueAll: 'Alle in die Warteschlange',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
@@ -500,6 +563,19 @@ const deTranslation = {
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
play: 'Abspielen',
|
||||
trackGenre: 'Genre',
|
||||
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
|
||||
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel und Album — z. B. Hörbuch, Audiobook, Spoken Word, …',
|
||||
genreBlocked: 'Keyword gesperrt',
|
||||
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
|
||||
genreAlreadyBlocked: 'Bereits gesperrt',
|
||||
blacklistToggle: 'Keyword-Filter',
|
||||
genreMixTitle: 'Genre-Mix',
|
||||
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
|
||||
genreMixLoadMore: '10 weitere laden',
|
||||
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
||||
filterPanelTitle: 'Filter',
|
||||
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel & Album.',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
@@ -511,6 +587,11 @@ const deTranslation = {
|
||||
minutes: 'Min.',
|
||||
track_one: '{{count}} Track',
|
||||
track_other: '{{count}} Tracks',
|
||||
filterPlaceholder: 'Playlists filtern…',
|
||||
colName: 'Name',
|
||||
colTracks: 'Tracks',
|
||||
colDuration: 'Dauer',
|
||||
noResults: 'Keine Playlists entsprechen dem Filter.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle Alben',
|
||||
@@ -616,8 +697,15 @@ const deTranslation = {
|
||||
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic'
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
randomMixTitle: 'Zufallsmix',
|
||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
|
||||
randomMixBlacklistAdd: 'Hinzufügen',
|
||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe',
|
||||
@@ -634,7 +722,7 @@ const deTranslation = {
|
||||
q5: 'Welche Tastenkürzel gibt es?',
|
||||
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
|
||||
q6: 'Was ist die Warteschlange?',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Listen-Icon in der Playerleiste. Tracks können per Drag & Drop umsortiert und als Playlist gespeichert werden.',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Panel-Icon oben rechts im Header (neben dem Now-Playing-Indikator). Tracks per Drag & Drop umsortieren, mit dem Shuffle-Button mischen oder als Playlist speichern.',
|
||||
q7: 'Wie öffne ich den Vollbild-Player?',
|
||||
a7: 'Klick auf das Album-Cover unten in der Playerleiste oder auf das Expand-Icon daneben. Mit Escape wieder schließen.',
|
||||
q8: 'Wie funktioniert die Wiederholfunktion?',
|
||||
@@ -660,6 +748,21 @@ const deTranslation = {
|
||||
a16: 'Psysonic nutzt die eingebaute Last.fm-Integration von Navidrome. Zuerst das Last.fm-Konto im Navidrome-Webinterface verbinden, dann Scrobbling in den Psysonic-Einstellungen aktivieren.',
|
||||
q17: 'Wann wird ein Scrobble gesendet?',
|
||||
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
|
||||
q22: 'Was ist die Waveform in der Playerleiste?',
|
||||
a22: 'Die Waveform-Leiste ersetzt den klassischen Fortschrittsbalken. Klick auf eine beliebige Stelle zum Springen, ziehen zum Scrubben. Der gespielte Teil leuchtet in einem Blau-Mauve-Farbverlauf, der gepufferte Bereich erscheint etwas heller, der ungespielter Teil ist abgeblendet.',
|
||||
q23: 'Wie benutze ich den MilkDrop-Visualizer?',
|
||||
a23: 'Vollbild-Player öffnen (Klick auf das Album-Cover in der Playerleiste), dann oben rechts auf das Waveform-Icon klicken. Der Visualizer startet mit einem zufälligen MilkDrop-Preset. Mit dem Shuffle-Button daneben durch Hunderte von Presets wechseln.',
|
||||
q24: 'Kann ich die Warteschlange mischen?',
|
||||
a24: 'Ja. Die Warteschlange öffnen und auf das Shuffle-Icon im Header klicken. Der aktuell laufende Track bleibt an Position 1 — alle restlichen Tracks werden zufällig neu geordnet.',
|
||||
q25: 'Öffnen Last.fm- und Wikipedia-Links auf Künstlerseiten im Browser?',
|
||||
a25: 'Nein — sie öffnen sich in einem eigenen In-App-Fenster, sodass du Psysonic nie verlassen musst. Das Fenster zeigt die vollständige Website und kann unabhängig geschlossen werden.',
|
||||
s7: 'Zufallsmix',
|
||||
q26: 'Was ist der Zufallsmix?',
|
||||
a26: 'Der Zufallsmix erstellt eine Playlist aus zufälligen Tracks deiner gesamten Bibliothek. Über "Zufallsmix" in der Seitenleiste erreichbar, dann "Neuer Mix" klicken. Die Anzahl der Tracks ist vor der Generierung einstellbar.',
|
||||
q27: 'Was ist der Keyword-Filter?',
|
||||
a27: 'Der Keyword-Filter schließt Tracks aus, deren Genre, Titel oder Album bestimmte Wörter enthält. Hörbücher werden automatisch gefiltert. Eigene Keywords in Einstellungen → Zufallsmix hinzufügen oder per Klick auf ein Genre-Tag direkt in der Trackliste.',
|
||||
q28: 'Was ist der Super-Genre-Mix?',
|
||||
a28: 'Der Super-Genre-Mix fasst die Bibliothek in übergeordnete Kategorien zusammen (Rock, Metal, Electronic, Jazz, Klassik usw.) und erstellt daraus einen fokussierten Mix. Einen Kategorie-Chip unterhalb der Trackliste auswählen. Ergebnisse erscheinen schrittweise, während die einzelnen Sub-Genres geladen werden.',
|
||||
s6: 'Problemlösung',
|
||||
q18: 'Cover und Künstlerbilder laden langsam.',
|
||||
a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.',
|
||||
@@ -683,6 +786,7 @@ const deTranslation = {
|
||||
delete: 'Löschen',
|
||||
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
||||
clear: 'Leeren',
|
||||
shuffle: 'Warteschlange mischen',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
@@ -690,16 +794,30 @@ const deTranslation = {
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
recentlyPlayed: 'Zuletzt gehört',
|
||||
mostPlayed: 'Meistgespielte Alben',
|
||||
highestRated: 'Höchstbewertete Alben',
|
||||
genreDistribution: 'Genre-Verteilung (Top 20)',
|
||||
loadMore: 'Mehr laden',
|
||||
statArtists: 'Künstler',
|
||||
statAlbums: 'Alben',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Alben',
|
||||
recentlyAdded: 'Neu hinzugefügt',
|
||||
decadeDistribution: 'Alben nach Jahrzehnt',
|
||||
decadeAlbums_one: '{{count}} Album',
|
||||
decadeAlbums_other: '{{count}} Alben',
|
||||
decadeUnknown: 'Unbekannt',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
openFullscreen: 'Vollbild-Player öffnen',
|
||||
fullscreen: 'Vollbild-Player',
|
||||
closeFullscreen: 'Vollbild schließen',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Nächstes Preset',
|
||||
closeTooltip: 'Schließen (Esc)',
|
||||
noTitle: 'Kein Titel',
|
||||
stop: 'Stop',
|
||||
|
||||
+59
-316
@@ -1,15 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
@@ -20,78 +19,6 @@ function sanitizeFilename(name: string): string {
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Strip dangerous tags/attributes from server-provided HTML (e.g. artist bios from Last.fm) */
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1,2,3,4,5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BioModalProps { bio: string; onClose: () => void; }
|
||||
function BioModal({ bio, onClose }: BioModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -101,6 +28,8 @@ export default function AlbumDetail() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
|
||||
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
@@ -136,8 +65,8 @@ export default function AlbumDetail() {
|
||||
if (!album) return;
|
||||
const tracks = album.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
@@ -146,8 +75,8 @@ export default function AlbumDetail() {
|
||||
if (!album) return;
|
||||
const tracks = album.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
};
|
||||
@@ -156,8 +85,7 @@ export default function AlbumDetail() {
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -175,7 +103,9 @@ export default function AlbumDetail() {
|
||||
setBioOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (albumName: string, albumId: string) => {
|
||||
const handleDownload = async () => {
|
||||
if (!album) return;
|
||||
const { name, id: albumId } = album.album;
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
@@ -205,13 +135,13 @@ export default function AlbumDetail() {
|
||||
const blob = new Blob(chunks);
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
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(albumName)}.zip`;
|
||||
a.download = `${sanitizeFilename(name)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -221,33 +151,31 @@ export default function AlbumDetail() {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
} finally {
|
||||
// keep bar visible at 100% for 3 seconds so user sees completion
|
||||
setTimeout(() => setDownloadProgress(null), 60000);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!album) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
const wasStarred = isStarred;
|
||||
setIsStarred(!wasStarred);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(album.album.id);
|
||||
if (wasStarred) await unstar(album.album.id);
|
||||
else await star(album.album.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred);
|
||||
setIsStarred(wasStarred);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
const wasStarred = starredSongs.has(song.id);
|
||||
const next = new Set(starredSongs);
|
||||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||||
setStarredSongs(next);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
if (wasStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
@@ -264,232 +192,47 @@ export default function AlbumDetail() {
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="album-detail animate-fade-in">
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
|
||||
<AlbumHeader
|
||||
info={info}
|
||||
songs={songs}
|
||||
coverUrl={coverUrl}
|
||||
coverKey={coverKey}
|
||||
resolvedCoverUrl={resolvedCoverUrl}
|
||||
isStarred={isStarred}
|
||||
downloadProgress={downloadProgress}
|
||||
bio={bio}
|
||||
bioOpen={bioOpen}
|
||||
onToggleStar={toggleStar}
|
||||
onDownload={handleDownload}
|
||||
onPlayAll={handlePlayAll}
|
||||
onEnqueueAll={handleEnqueueAll}
|
||||
onBio={handleBio}
|
||||
onCloseBio={() => setBioOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
<span className="download-hint">
|
||||
<Info size={12} />
|
||||
{t('albumDetail.downloadHintShort')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tracklist">
|
||||
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
return discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span>
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => handlePlaySong(song)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, track, 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec" style={{ marginTop: 0 }}>
|
||||
{codecLabel(song)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Total row */}
|
||||
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlbumTrackList
|
||||
songs={songs}
|
||||
hasVariousArtists={hasVariousArtists}
|
||||
currentTrack={currentTrack}
|
||||
isPlaying={isPlaying}
|
||||
hoveredSongId={hoveredSongId}
|
||||
setHoveredSongId={setHoveredSongId}
|
||||
ratings={ratings}
|
||||
starredSongs={starredSongs}
|
||||
onPlaySong={handlePlaySong}
|
||||
onRate={handleRate}
|
||||
onToggleSongStar={toggleSongStar}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
|
||||
<div style={{ borderTop: '1px solid var(--border-subtle)', marginBottom: '2rem' }} />
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-related">
|
||||
<div className="album-related-divider" />
|
||||
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -77,7 +77,16 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const openLink = (url: string) => open(url);
|
||||
const openLink = (url: string, title: string) => {
|
||||
const label = `browser_${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
url,
|
||||
title,
|
||||
width: 1100,
|
||||
height: 780,
|
||||
center: true,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
@@ -183,12 +192,12 @@ export default function ArtistDetail() {
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, `${artist.name} — Last.fm`)}>
|
||||
<LastfmIcon size={14} />
|
||||
Last.fm
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
|
||||
<ExternalLink size={14} />
|
||||
Wikipedia
|
||||
</button>
|
||||
|
||||
+63
-36
@@ -3,7 +3,8 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play } from 'lucide-react';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Favorites() {
|
||||
@@ -13,7 +14,9 @@ export default function Favorites() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack } = usePlayerStore();
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getStarred()
|
||||
@@ -56,44 +59,68 @@ export default function Favorites() {
|
||||
|
||||
{songs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('favorites.songs')}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
<ListPlus size={15} />
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
{songs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px 1fr 60px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
<div className="tracklist-header tracklist-va">
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{songs.map((song, i) => {
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info">
|
||||
<span className="track-title" title={song.title}>{song.title}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className="track-artist"
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
+14
-1
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FaqItem { q: string; a: string; }
|
||||
@@ -42,6 +42,9 @@ export default function Help() {
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q23'), a: t('help.a23') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -51,6 +54,7 @@ export default function Help() {
|
||||
{ q: t('help.q9'), a: t('help.a9') },
|
||||
{ q: t('help.q10'), a: t('help.a10') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
{ q: t('help.q25'), a: t('help.a25') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -71,6 +75,15 @@ export default function Help() {
|
||||
{ q: t('help.q17'), a: t('help.a17') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Shuffle size={18} />,
|
||||
title: t('help.s7'),
|
||||
items: [
|
||||
{ q: t('help.q26'), a: t('help.a26') },
|
||||
{ q: t('help.q27'), a: t('help.a27') },
|
||||
{ q: t('help.q28'), a: t('help.a28') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Wrench size={18} />,
|
||||
title: t('help.s6'),
|
||||
|
||||
+5
-3
@@ -8,6 +8,7 @@ export default function Home() {
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -15,12 +16,13 @@ export default function Home() {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
]).then(([s, n, r, f]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setRandom(r);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
@@ -47,7 +49,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Hero />
|
||||
<Hero albums={heroAlbums} />
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -49,7 +51,7 @@ export default function NewReleases() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
|
||||
+95
-76
@@ -1,13 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { ListMusic, Play, Trash2 } from 'lucide-react';
|
||||
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortKey = 'name' | 'songCount' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
label, sortKey, current, dir, onSort
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: SortKey;
|
||||
current: SortKey;
|
||||
dir: SortDir;
|
||||
onSort: (k: SortKey) => void;
|
||||
}) {
|
||||
const active = current === sortKey;
|
||||
return (
|
||||
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
|
||||
{label}
|
||||
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
@@ -15,104 +45,93 @@ export default function Playlists() {
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists()
|
||||
.then(data => {
|
||||
setPlaylists(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load playlists', err);
|
||||
setLoading(false);
|
||||
});
|
||||
.then(data => { setPlaylists(data); setLoading(false); })
|
||||
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists();
|
||||
}, []);
|
||||
useEffect(() => { fetchPlaylists(); }, []);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const handlePlay = async (id: string) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks = data.songs.map((s: any) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
||||
coverArt: s.coverArt, track: s.track, year: s.year,
|
||||
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(tracks[0], tracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to play playlist', e);
|
||||
}
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
} catch (e) { console.error('Failed to play playlist', e); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('playlists.confirmDelete', { name }))) {
|
||||
try {
|
||||
await deletePlaylist(id);
|
||||
fetchPlaylists();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete playlist', e);
|
||||
}
|
||||
try { await deletePlaylist(id); fetchPlaylists(); }
|
||||
catch (e) { console.error('Failed to delete playlist', e); }
|
||||
}
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.toLowerCase();
|
||||
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
|
||||
return [...filtered].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
|
||||
else cmp = a.duration - b.duration;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [playlists, filter, sortKey, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<ListMusic size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{t('playlists.title')}</h1>
|
||||
<div className="playlist-page-header">
|
||||
<h1 className="page-title">{t('playlists.title')}</h1>
|
||||
<input
|
||||
className="playlist-filter-input"
|
||||
type="search"
|
||||
placeholder={t('playlists.filterPlaceholder')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">{t('playlists.loading')}</div>
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>
|
||||
{t('playlists.empty')}
|
||||
</div>
|
||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
|
||||
{playlists.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--surface0)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
border: '1px solid var(--surface1)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
className="hover-card"
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '0 0 0.25rem 0', color: 'var(--text)' }} className="truncate">
|
||||
{p.name}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--subtext0)' }}>
|
||||
{t('playlists.track', { count: p.songCount })} • {Math.floor(p.duration / 60)} {t('playlists.minutes')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="playlist-list">
|
||||
<div className="playlist-list-header">
|
||||
<div />
|
||||
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePlay(p.id)}
|
||||
style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.play')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip={t('playlists.deleteTooltip')}
|
||||
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<div className="empty-state">{t('playlists.noResults')}</div>
|
||||
) : visible.map(p => (
|
||||
<div key={p.id} className="playlist-row">
|
||||
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<span className="playlist-name truncate">{p.name}</span>
|
||||
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
|
||||
<span className="playlist-meta">{formatDuration(p.duration)}</span>
|
||||
<button
|
||||
className="btn btn-ghost playlist-delete-btn"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip={t('playlists.deleteTooltip')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,61 +4,30 @@ import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const INTERVAL_MS = 30000;
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [renderKey, setRenderKey] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList('random', ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
setRenderKey(k => k + 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCycle = useCallback(() => {
|
||||
// Clear existing timers
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (progressRef.current) clearInterval(progressRef.current);
|
||||
|
||||
// Reset progress bar
|
||||
setProgress(0);
|
||||
const startTime = Date.now();
|
||||
progressRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
|
||||
}, 100);
|
||||
|
||||
// Auto-refresh
|
||||
timerRef.current = setInterval(() => {
|
||||
load().then(() => startCycle());
|
||||
}, INTERVAL_MS);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
load().then(() => startCycle());
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (progressRef.current) clearInterval(progressRef.current);
|
||||
};
|
||||
}, [load, startCycle]);
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
load().then(() => startCycle());
|
||||
};
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -66,7 +35,7 @@ export default function RandomAlbums() {
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleManualRefresh}
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
@@ -75,17 +44,12 @@ export default function RandomAlbums() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Countdown progress bar */}
|
||||
<div className="random-albums-progress">
|
||||
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap animate-fade-in" key={renderKey}>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+335
-13
@@ -1,9 +1,35 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Star, RefreshCw } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AUDIOBOOK_GENRES = [
|
||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||
'audiobook', 'audio book', 'spoken word', 'spokenword',
|
||||
'podcast', 'kapitel', 'thriller', 'krimi', 'speech',
|
||||
'fantasy', 'comedy', 'literature',
|
||||
];
|
||||
|
||||
interface SuperGenre {
|
||||
id: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
const SUPER_GENRES: SuperGenre[] = [
|
||||
{ id: 'metal', label: 'Metal', keywords: ['metal', 'thrash', 'doom', 'sludge', 'hardcore', 'grindcore', 'deathcore', 'metalcore', 'stoner', 'crust', 'black', 'death'] },
|
||||
{ id: 'rock', label: 'Rock', keywords: ['rock', 'punk', 'grunge', 'alternative', 'indie', 'post-rock', 'prog', 'garage', 'psychedelic', 'shoegaze'] },
|
||||
{ id: 'pop', label: 'Pop', keywords: ['pop', 'synth-pop', 'dream pop', 'electropop', 'indie pop', 'dance pop'] },
|
||||
{ id: 'electronic', label: 'Electronic', keywords: ['electronic', 'techno', 'trance', 'ambient', 'edm', 'house', 'dubstep', 'drum and bass', 'dnb', 'electro', 'idm', 'synthwave', 'darkwave', 'industrial'] },
|
||||
{ id: 'jazz', label: 'Jazz', keywords: ['jazz', 'blues', 'soul', 'funk', 'swing', 'bebop', 'fusion'] },
|
||||
{ id: 'classical', label: 'Classical', keywords: ['classical', 'orchestra', 'symphony', 'baroque', 'opera', 'chamber', 'romantic'] },
|
||||
{ id: 'hiphop', label: 'Hip-Hop', keywords: ['hip-hop', 'hip hop', 'rap', 'r&b', 'rnb', 'trap', 'grime'] },
|
||||
{ id: 'country', label: 'Country', keywords: ['country', 'folk', 'bluegrass', 'americana', 'western'] },
|
||||
{ id: 'world', label: 'World', keywords: ['world', 'latin', 'reggae', 'ska', 'afro', 'celtic', 'flamenco', 'bossa nova'] },
|
||||
];
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -16,8 +42,22 @@ export default function RandomMix() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
|
||||
// Blacklist panel state
|
||||
const [blacklistOpen, setBlacklistOpen] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
|
||||
// Genre Mix state
|
||||
const [serverGenres, setServerGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
|
||||
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
|
||||
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
@@ -32,12 +72,31 @@ export default function RandomMix() {
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSongs();
|
||||
getGenres().then(setServerGenres).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filteredSongs = songs.filter(song => {
|
||||
if (!excludeAudiobooks) return true;
|
||||
const checkText = (text: string) => {
|
||||
const t = text.toLowerCase();
|
||||
if (AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
|
||||
if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true;
|
||||
return false;
|
||||
};
|
||||
if (song.genre && checkText(song.genre)) return false;
|
||||
if (song.title && checkText(song.title)) return false;
|
||||
if (song.album && checkText(song.album)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (songs.length > 0) playTrack(songs[0], songs);
|
||||
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
@@ -57,6 +116,49 @@ export default function RandomMix() {
|
||||
}
|
||||
};
|
||||
|
||||
// Compute which super-genres have matching server genres
|
||||
const availableSuperGenres = SUPER_GENRES.filter(sg =>
|
||||
serverGenres.some(sg2 =>
|
||||
sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))
|
||||
)
|
||||
);
|
||||
|
||||
const loadGenreMix = async (superGenreId: string) => {
|
||||
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
|
||||
if (!sg) return;
|
||||
const matched = serverGenres
|
||||
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
|
||||
.map(sg2 => sg2.value);
|
||||
setGenreMixLoading(true);
|
||||
setGenreMixSongs([]);
|
||||
|
||||
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
|
||||
const accumulated: SubsonicSong[] = [];
|
||||
let resolved = 0;
|
||||
|
||||
await Promise.allSettled(matched.map(g =>
|
||||
getRandomSongs(perGenre, g, 45000).then(songs => {
|
||||
accumulated.push(...songs);
|
||||
resolved++;
|
||||
// Show first batch immediately; update on every subsequent resolve
|
||||
setGenreMixSongs([...accumulated]);
|
||||
if (resolved === 1) setGenreMixLoading(false);
|
||||
}).catch(() => { resolved++; })
|
||||
));
|
||||
|
||||
// Final shuffle once all requests are done
|
||||
setGenreMixSongs(prev => {
|
||||
const s = [...prev];
|
||||
for (let i = s.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[s[i], s[j]] = [s[j], s[i]];
|
||||
}
|
||||
return s.slice(0, 50);
|
||||
});
|
||||
setGenreMixLoading(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
@@ -66,35 +168,214 @@ export default function RandomMix() {
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || filteredSongs.length === 0}>
|
||||
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && songs.length === 0 ? (
|
||||
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '1px',
|
||||
background: 'var(--border)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
marginBottom: '2rem',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Left: Blacklist */}
|
||||
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('randomMix.filterPanelTitle')}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excludeAudiobooks}
|
||||
onChange={e => setExcludeAudiobooks(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{t('randomMix.excludeAudiobooks')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{t('randomMix.excludeAudiobooksDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '3px 8px', marginBottom: blacklistOpen ? '0.5rem' : 0 }}
|
||||
onClick={() => setBlacklistOpen(v => !v)}
|
||||
>
|
||||
{blacklistOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{t('randomMix.blacklistToggle')} ({customGenreBlacklist.length})
|
||||
</button>
|
||||
|
||||
{blacklistOpen && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem', minHeight: 24 }}>
|
||||
{customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '1px 7px', fontSize: 11, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 13 }}
|
||||
onClick={() => setCustomGenreBlacklist(customGenreBlacklist.filter(g => g !== genre))}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>{t('settings.randomMixBlacklistAdd')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Genre Mix */}
|
||||
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('randomMix.genreMixTitle')}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{serverGenres.length === 0 ? (
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
) : availableSuperGenres.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
|
||||
) : (
|
||||
availableSuperGenres.map(sg => (
|
||||
<button
|
||||
key={sg.id}
|
||||
className={`btn ${selectedSuperGenre === sg.id ? 'btn-primary' : 'btn-surface'}`}
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
onClick={() => { setSelectedSuperGenre(sg.id); loadGenreMix(sg.id); }}
|
||||
disabled={genreMixLoading}
|
||||
>
|
||||
{sg.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Genre Mix tracklist (shown when a super-genre is selected) */}
|
||||
{(genreMixLoading || genreMixSongs.length > 0) && (
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
|
||||
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
|
||||
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{genreMixLoading && genreMixSongs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span>{t('randomMix.trackGenre')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
{genreMixSongs.map(song => (
|
||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }, 'song'); }}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info"><span className="track-title" data-tooltip={song.title}>{song.title}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
|
||||
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSuperGenre && (loading && songs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}>ⓘ</span>
|
||||
</span>
|
||||
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
|
||||
{songs.map((song) => (
|
||||
{filteredSongs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, filteredSongs)}
|
||||
role="row"
|
||||
draggable
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating };
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
@@ -108,7 +389,7 @@ export default function RandomMix() {
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, filteredSongs); }}
|
||||
data-tooltip={t('randomMix.play')}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
@@ -126,6 +407,46 @@ export default function RandomMix() {
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const genre = song.genre;
|
||||
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</div>;
|
||||
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||
const justAdded = addedGenre === genre;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
|
||||
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
|
||||
border: 'none',
|
||||
cursor: isBlocked ? 'default' : 'pointer',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 'auto',
|
||||
minHeight: 'unset',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isBlocked) return;
|
||||
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||
if (!already) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||
setAddedGenre(genre);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
@@ -143,7 +464,8 @@ export default function RandomMix() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Play, Search } from 'lucide-react';
|
||||
import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(s: number) {
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function SearchResults() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const query = params.get('q') ?? '';
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setResults(null); return; }
|
||||
setLoading(true);
|
||||
search(query, { artistCount: 20, albumCount: 20, songCount: 50 })
|
||||
.then(r => setResults(r))
|
||||
.finally(() => setLoading(false));
|
||||
}, [query]);
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
|
||||
const playSong = (song: SubsonicSong, list: SubsonicSong[]) => {
|
||||
playTrack({
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
|
||||
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating,
|
||||
}, list.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
||||
coverArt: s.coverArt, year: s.year, bitRate: s.bitRate,
|
||||
suffix: s.suffix, userRating: s.userRating,
|
||||
})));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
<div style={{ marginBottom: '-1.5rem' }}>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<Search size={22} />
|
||||
{query ? t('search.resultsFor', { query }) : t('search.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
)}
|
||||
|
||||
{!loading && query && !hasResults && (
|
||||
<div className="empty-state">{t('search.noResults', { query })}</div>
|
||||
)}
|
||||
|
||||
{!loading && results && (
|
||||
<>
|
||||
{results.artists.length > 0 && (
|
||||
<ArtistRow title={t('search.artists')} artists={results.artists} />
|
||||
)}
|
||||
|
||||
{results.albums.length > 0 && (
|
||||
<AlbumRow title={t('search.albums')} albums={results.albums} />
|
||||
)}
|
||||
|
||||
{results.songs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
|
||||
<div />
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div>{t('search.album')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{results.songs.map(song => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
|
||||
onDoubleClick={() => playSong(song, results.songs)}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
|
||||
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={e => { e.stopPropagation(); playSong(song, results.songs); }}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info">
|
||||
<span className="track-title" title={song.title}>{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
|
||||
<span className="track-codec" style={{ alignSelf: 'center' }}>
|
||||
{[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+94
-2
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
@@ -10,6 +11,8 @@ import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||
@@ -76,6 +79,7 @@ export default function Settings() {
|
||||
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
|
||||
const testConnection = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
@@ -353,6 +357,94 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Random Mix */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Shuffle size={18} />
|
||||
<h2>{t('settings.randomMixTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{/* Description */}
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
|
||||
{/* Custom blacklist chips */}
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add input */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
{/* Hardcoded list */}
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Logout */}
|
||||
<section className="settings-section">
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
|
||||
@@ -374,7 +466,7 @@ export default function Settings() {
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.aboutVersion')} 1.0.10
|
||||
{t('settings.aboutVersion')} {appVersion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+59
-47
@@ -1,26 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getGenres().catch(() => [])
|
||||
]).then(([f, h, g]) => {
|
||||
setFrequent(f);
|
||||
setHighest(h);
|
||||
// Sort genres by album count or song count
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20)); // Top 20 genres
|
||||
getGenres().catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([rc, fr, hi, g, a]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20));
|
||||
setArtistCount(a.length);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -33,29 +37,44 @@ export default function Statistics() {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) {
|
||||
setter(prev => [...prev, ...newItems]);
|
||||
}
|
||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
}
|
||||
};
|
||||
|
||||
const 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 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<BarChart3 size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{t('statistics.title')}</h1>
|
||||
</div>
|
||||
<h1 className="page-title">{t('statistics.title')}</h1>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
<div className="stats-page">
|
||||
|
||||
<div className="stats-overview">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="stats-card">
|
||||
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
|
||||
<span className="stats-card-label">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
|
||||
)}
|
||||
|
||||
<AlbumRow
|
||||
title={t('statistics.mostPlayed')}
|
||||
@@ -72,36 +91,29 @@ export default function Statistics() {
|
||||
/>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<h2>{t('statistics.genreDistribution')}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem', background: 'var(--surface0)', padding: '1.5rem', borderRadius: '12px' }}>
|
||||
{genres.map(genre => {
|
||||
const percentage = (genre.songCount / maxGenreCount) * 100;
|
||||
return (
|
||||
<div key={genre.value} style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', color: 'var(--text)' }}>
|
||||
<span>{genre.value}</span>
|
||||
<span style={{ color: 'var(--subtext0)' }}>{genre.songCount} Songs</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '8px', background: 'var(--surface2)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 1s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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 })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="genre-bar-track">
|
||||
<div
|
||||
className="genre-bar-fill"
|
||||
style={{ width: `${(genre.songCount / maxGenreCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface AuthState {
|
||||
scrobblingEnabled: boolean;
|
||||
maxCacheMb: number;
|
||||
downloadFolder: string;
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
@@ -44,6 +46,8 @@ interface AuthState {
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
setExcludeAudiobooks: (v: boolean) => void;
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
logout: () => void;
|
||||
|
||||
// Derived
|
||||
@@ -68,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
scrobblingEnabled: true,
|
||||
maxCacheMb: 500,
|
||||
downloadFolder: '',
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -109,6 +115,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 }),
|
||||
|
||||
logout: () => set({ isLoggedIn: false }),
|
||||
|
||||
|
||||
+338
-373
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { Howl } from 'howler';
|
||||
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 { useAuthStore } from './authStore';
|
||||
|
||||
@@ -26,14 +27,11 @@ interface PlayerState {
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
progress: number; // 0–1
|
||||
buffered: number; // 0–1
|
||||
buffered: number; // 0–1 (unused in Rust backend, kept for UI compat)
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
howl: Howl | null;
|
||||
prefetched: Map<string, Howl>;
|
||||
scrobbled: boolean;
|
||||
|
||||
// Actions
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
@@ -46,79 +44,135 @@ interface PlayerState {
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
clearQueue: () => void;
|
||||
prefetchUpcoming: (fromIndex: number, queue: Track[]) => void;
|
||||
|
||||
|
||||
isQueueVisible: boolean;
|
||||
toggleQueue: () => void;
|
||||
|
||||
isFullscreenOpen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
|
||||
|
||||
repeatMode: 'off' | 'all' | 'one';
|
||||
toggleRepeat: () => void;
|
||||
|
||||
reorderQueue: (startIndex: number, endIndex: number) => void;
|
||||
removeTrack: (index: number) => void;
|
||||
|
||||
shuffleQueue: () => void;
|
||||
|
||||
initializeFromServerQueue: () => Promise<void>;
|
||||
|
||||
// Context Menu Global State
|
||||
contextMenu: {
|
||||
isOpen: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
|
||||
queueIndex?: number; // Only for 'queue-item'
|
||||
queueIndex?: number;
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
}
|
||||
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||
|
||||
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
|
||||
// Used by resume() to decide between audio_resume (warm) vs audio_play (cold start).
|
||||
let isAudioPaused = false;
|
||||
|
||||
// JS-side generation counter. Incremented on every playTrack() call.
|
||||
// The invoke().catch() error handler captures its own gen and bails if
|
||||
// playGeneration has moved on, preventing stale errors from skipping wrong tracks.
|
||||
let playGeneration = 0;
|
||||
|
||||
// Debounce timer for seek slider drags.
|
||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let gstSeeking = false; // true while GStreamer is processing a seek
|
||||
let pendingSeekTime: number | null = null; // queue at most one seek
|
||||
let gstSeekWatchdog: ReturnType<typeof setTimeout> | null = null; // safety release if onseek never fires
|
||||
let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here
|
||||
let gaplessPrewarmedId: string | null = null; // track id whose prefetched Howl has been silently pre-started
|
||||
let hangLastTime = -1; // last observed currentTime for hang detection
|
||||
let hangStallTime = 0; // Date.now() when currentTime last moved
|
||||
|
||||
function clearProgress() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
}
|
||||
// Guard against rapid double-click play/pause sending two state transitions
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
let togglePlayLock = false;
|
||||
|
||||
function armGstWatchdog(cb: () => void) {
|
||||
if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog);
|
||||
gstSeekWatchdog = setTimeout(() => {
|
||||
gstSeekWatchdog = null;
|
||||
cb();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function disarmGstWatchdog() {
|
||||
if (gstSeekWatchdog) { clearTimeout(gstSeekWatchdog); gstSeekWatchdog = null; }
|
||||
}
|
||||
|
||||
// Helper to debounce or fire queue syncs
|
||||
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||
if (syncTimeout) clearTimeout(syncTimeout);
|
||||
syncTimeout = setTimeout(() => {
|
||||
// Collect up to 1000 track IDs just in case it's huge
|
||||
const ids = queue.slice(0, 1000).map(t => t.id);
|
||||
// Convert currentTime (seconds) to expected format (milliseconds)
|
||||
const pos = Math.floor(currentTime * 1000);
|
||||
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
|
||||
console.error('Failed to sync play queue to server', err);
|
||||
});
|
||||
}, 1500); // 1.5s debounce
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
|
||||
|
||||
function handleAudioPlaying(_duration: number) {
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
}
|
||||
|
||||
function handleAudioProgress(current_time: number, duration: number) {
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track) return;
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
const progress = current_time / dur;
|
||||
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
||||
|
||||
// Scrobble at 50%
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||
isAudioPaused = false;
|
||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue);
|
||||
} else {
|
||||
usePlayerStore.getState().next();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleAudioError(message: string) {
|
||||
console.error('[psysonic] Audio error from backend:', message);
|
||||
isAudioPaused = false;
|
||||
const gen = playGeneration;
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
usePlayerStore.getState().next();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Tauri event listeners for the Rust audio engine.
|
||||
* Returns a cleanup function — pass it to useEffect's return value so that
|
||||
* React StrictMode (which double-invokes effects in dev) tears down the first
|
||||
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||
*/
|
||||
export function initAudioListeners(): () => void {
|
||||
const pending = [
|
||||
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
|
||||
handleAudioProgress(payload.current_time, payload.duration)
|
||||
),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
];
|
||||
|
||||
return () => {
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const usePlayerStore = create<PlayerState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -130,8 +184,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
volume: 0.8,
|
||||
howl: null,
|
||||
prefetched: new Map(),
|
||||
scrobbled: false,
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
@@ -139,357 +191,270 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
|
||||
openContextMenu: (x, y, item, type, queueIndex) => set({
|
||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex }
|
||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
|
||||
}),
|
||||
closeContextMenu: () => set(state => ({
|
||||
contextMenu: { ...state.contextMenu, isOpen: false }
|
||||
contextMenu: { ...state.contextMenu, isOpen: false },
|
||||
})),
|
||||
|
||||
toggleQueue: () => set((state) => ({ isQueueVisible: !state.isQueueVisible })),
|
||||
toggleFullscreen: () => set((state) => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
|
||||
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
|
||||
toggleRepeat: () => set((state) => {
|
||||
const modes = ['off', 'all', 'one'] as const;
|
||||
const nextIdx = (modes.indexOf(state.repeatMode) + 1) % modes.length;
|
||||
return { repeatMode: modes[nextIdx] };
|
||||
}),
|
||||
toggleRepeat: () => set(state => {
|
||||
const modes = ['off', 'all', 'one'] as const;
|
||||
return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] };
|
||||
}),
|
||||
|
||||
stop: () => {
|
||||
get().howl?.stop();
|
||||
get().howl?.seek(0);
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
},
|
||||
// ── stop ────────────────────────────────────────────────────────────────
|
||||
stop: () => {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
},
|
||||
|
||||
playTrack: (track, queue) => {
|
||||
const state = get();
|
||||
// Stop current
|
||||
state.howl?.unload();
|
||||
clearProgress();
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||
disarmGstWatchdog();
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
hangLastTime = -1;
|
||||
hangStallTime = 0;
|
||||
gaplessPrewarmedId = null;
|
||||
// ── playTrack ────────────────────────────────────────────────────────────
|
||||
playTrack: (track, queue) => {
|
||||
const gen = ++playGeneration;
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
const state = get();
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
|
||||
// Reuse a prefetched Howl if available — it's already connected and buffering
|
||||
const prefetchMap = state.prefetched;
|
||||
let howl: Howl;
|
||||
let gaplessHandoff = false;
|
||||
if (prefetchMap.has(track.id)) {
|
||||
howl = prefetchMap.get(track.id)!;
|
||||
prefetchMap.delete(track.id);
|
||||
set({ prefetched: new Map(prefetchMap) });
|
||||
if (howl.playing()) {
|
||||
// Gapless: pipeline already running — pause, seek to 0, then play
|
||||
gaplessHandoff = true;
|
||||
howl.pause();
|
||||
hangRecoveryPos = 0; // onplay will seek to position 0
|
||||
}
|
||||
howl.volume(state.volume);
|
||||
} else {
|
||||
howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume });
|
||||
}
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
set({
|
||||
currentTrack: track,
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
scrobbled: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
});
|
||||
|
||||
howl.on('play', () => {
|
||||
set({ isPlaying: true });
|
||||
reportNowPlaying(track.id);
|
||||
const url = buildStreamUrl(track.id);
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: track.duration,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// If recovering from a pipeline hang, seek to the saved position
|
||||
if (hangRecoveryPos !== null) {
|
||||
const pos = hangRecoveryPos;
|
||||
hangRecoveryPos = null;
|
||||
gstSeeking = true;
|
||||
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; });
|
||||
setTimeout(() => { howl.seek(pos); }, 50);
|
||||
}
|
||||
reportNowPlaying(track.id);
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
|
||||
set({ scrobbled: false });
|
||||
hangStallTime = Date.now();
|
||||
hangLastTime = -1;
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
const h = get().howl;
|
||||
if (!h) return;
|
||||
const s = h.seek();
|
||||
const cur = typeof s === 'number' ? s : 0;
|
||||
const dur = h.duration() || 1;
|
||||
const prog = cur / dur;
|
||||
resume: () => {
|
||||
const { currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
|
||||
// Read buffered ranges from the underlying <audio> element
|
||||
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
||||
if (audioNode?.buffered && audioNode.duration > 0) {
|
||||
let totalBuf = 0;
|
||||
for (let i = 0; i < audioNode.buffered.length; i++) {
|
||||
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
||||
}
|
||||
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
||||
if (isAudioPaused) {
|
||||
// Rust engine has audio loaded but paused — just resume it.
|
||||
invoke('audio_resume').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: true });
|
||||
} else {
|
||||
set({ currentTime: cur, progress: prog });
|
||||
// Cold start (app relaunch) — audio is not loaded in Rust; re-download.
|
||||
const gen = ++playGeneration;
|
||||
const vol = get().volume;
|
||||
set({ isPlaying: true });
|
||||
invoke('audio_play', {
|
||||
url: buildStreamUrl(currentTrack.id),
|
||||
volume: vol,
|
||||
durationHint: currentTrack.duration,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
syncQueueToServer(queue, currentTrack, currentTime);
|
||||
}
|
||||
},
|
||||
|
||||
// Hang detection: if playing but currentTime hasn't moved in 5s, recover
|
||||
if (Math.abs(cur - hangLastTime) > 0.05) {
|
||||
hangLastTime = cur;
|
||||
hangStallTime = Date.now();
|
||||
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) {
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) {
|
||||
hangRecoveryPos = cur;
|
||||
hangStallTime = Date.now(); // prevent re-trigger while recovering
|
||||
get().playTrack(ct, q);
|
||||
}
|
||||
togglePlay: () => {
|
||||
if (togglePlayLock) return;
|
||||
togglePlayLock = true;
|
||||
setTimeout(() => { togglePlayLock = false; }, 300);
|
||||
const { isPlaying } = get();
|
||||
isPlaying ? get().pause() : get().resume();
|
||||
},
|
||||
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: () => {
|
||||
const { queue, queueIndex, repeatMode } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
previous: () => {
|
||||
const { queue, queueIndex, currentTime } = get();
|
||||
if (currentTime > 3) {
|
||||
// Restart current track from the beginning.
|
||||
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
||||
set({ progress: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const prevIdx = queueIndex - 1;
|
||||
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
|
||||
},
|
||||
|
||||
// Gapless pre-warm: start next track's Howl silently ~1s before end
|
||||
if (!gaplessPrewarmedId && dur > 2 && dur - cur < 1.0) {
|
||||
const { queue: q, queueIndex: qi, repeatMode: rm, prefetched: pf } = get();
|
||||
const nextIdx = qi + 1;
|
||||
const nextTrack = nextIdx < q.length ? q[nextIdx]
|
||||
: (rm === 'all' && q.length > 0 ? q[0] : null);
|
||||
if (nextTrack && pf.has(nextTrack.id)) {
|
||||
const nh = pf.get(nextTrack.id)!;
|
||||
if (!nh.playing()) { nh.volume(0); nh.play(); gaplessPrewarmedId = nextTrack.id; }
|
||||
// ── seek ─────────────────────────────────────────────────────────────────
|
||||
// 100 ms debounce collapses rapid slider drags into one actual seek.
|
||||
seek: (progress) => {
|
||||
const { currentTrack } = get();
|
||||
if (!currentTrack) return;
|
||||
const dur = currentTrack.duration;
|
||||
if (!dur || !isFinite(dur)) return;
|
||||
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
|
||||
set({ progress: time / dur, currentTime: time });
|
||||
if (seekDebounce) clearTimeout(seekDebounce);
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
invoke('audio_seek', { seconds: time }).catch(console.error);
|
||||
}, 100);
|
||||
},
|
||||
|
||||
// ── volume ───────────────────────────────────────────────────────────────
|
||||
setVolume: (v) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
||||
set({ volume: clamped });
|
||||
},
|
||||
|
||||
setProgress: (t, duration) => {
|
||||
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
||||
},
|
||||
|
||||
// ── queue management ─────────────────────────────────────────────────────
|
||||
enqueue: (tracks) => {
|
||||
set(state => {
|
||||
const newQueue = [...state.queue, ...tracks];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
|
||||
clearQueue: () => {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
reorderQueue: (startIndex, endIndex) => {
|
||||
const { queue, queueIndex, currentTrack } = get();
|
||||
const result = Array.from(queue);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
let newIndex = queueIndex;
|
||||
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
|
||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
shuffleQueue: () => {
|
||||
const { queue, currentTrack } = get();
|
||||
if (queue.length < 2) return;
|
||||
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1;
|
||||
const others = queue.filter((_, i) => i !== currentIdx);
|
||||
for (let i = others.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[others[i], others[j]] = [others[j], others[i]];
|
||||
}
|
||||
const result = currentIdx >= 0
|
||||
? [queue[currentIdx], ...others]
|
||||
: others;
|
||||
const newIndex = currentIdx >= 0 ? 0 : -1;
|
||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
removeTrack: (index) => {
|
||||
const { queue, queueIndex } = get();
|
||||
const newQueue = [...queue];
|
||||
newQueue.splice(index, 1);
|
||||
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
|
||||
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
// ── server queue restore ─────────────────────────────────────────────────
|
||||
initializeFromServerQueue: async () => {
|
||||
try {
|
||||
const q = await getPlayQueue();
|
||||
if (q.songs.length > 0) {
|
||||
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
||||
coverArt: s.coverArt, track: s.track, year: s.year,
|
||||
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
|
||||
let currentTrack = mappedTracks[0];
|
||||
let queueIndex = 0;
|
||||
|
||||
if (q.current) {
|
||||
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
||||
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
|
||||
}
|
||||
|
||||
// Prefer the server position if available; otherwise keep the
|
||||
// localStorage-persisted currentTime (more reliable than server
|
||||
// queue position, which may not flush before app close).
|
||||
const serverTime = q.position ? q.position / 1000 : 0;
|
||||
const localTime = get().currentTime;
|
||||
set({
|
||||
queue: mappedTracks,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
currentTime: serverTime > 0 ? serverTime : localTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Scrobble at 50%
|
||||
if (prog >= 0.5 && !get().scrobbled) {
|
||||
set({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize queue from server', e);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Prefetch next 3
|
||||
get().prefetchUpcoming(idx + 1, newQueue);
|
||||
});
|
||||
|
||||
howl.on('end', () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
const { repeatMode, currentTrack, queue } = get();
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
get().playTrack(currentTrack, queue);
|
||||
} else {
|
||||
get().next();
|
||||
}
|
||||
});
|
||||
|
||||
howl.on('stop', () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
|
||||
howl.on('seek', () => {
|
||||
disarmGstWatchdog();
|
||||
gstSeeking = false;
|
||||
hangLastTime = -1;
|
||||
hangStallTime = Date.now();
|
||||
if (pendingSeekTime !== null) {
|
||||
const t = pendingSeekTime;
|
||||
pendingSeekTime = null;
|
||||
gstSeeking = true;
|
||||
armGstWatchdog(() => {
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); }
|
||||
});
|
||||
get().howl?.seek(t);
|
||||
}
|
||||
});
|
||||
|
||||
howl.play(); // for gapless: resumes from paused state, onplay fires and seeks to 0 via hangRecoveryPos
|
||||
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, buffered: 0, currentTime: 0 });
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
get().howl?.pause();
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
const { howl, currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
if (!howl) {
|
||||
// Cold start from restored state (e.g. app relaunch) — resume from saved position
|
||||
if (currentTime > 0) hangRecoveryPos = currentTime;
|
||||
get().playTrack(currentTrack, queue);
|
||||
return;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'psysonic-player',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
volume: state.volume,
|
||||
repeatMode: state.repeatMode,
|
||||
currentTrack: state.currentTrack,
|
||||
queue: state.queue,
|
||||
queueIndex: state.queueIndex,
|
||||
currentTime: state.currentTime,
|
||||
} as Partial<PlayerState>),
|
||||
}
|
||||
howl.play();
|
||||
set({ isPlaying: true });
|
||||
},
|
||||
|
||||
togglePlay: () => {
|
||||
const { isPlaying } = get();
|
||||
isPlaying ? get().pause() : get().resume();
|
||||
},
|
||||
|
||||
next: () => {
|
||||
const { queue, queueIndex, repeatMode } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
}
|
||||
},
|
||||
|
||||
previous: () => {
|
||||
const { howl, queue, queueIndex, currentTime } = get();
|
||||
if (currentTime > 3) {
|
||||
howl?.seek(0);
|
||||
set({ progress: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const prevIdx = queueIndex - 1;
|
||||
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
|
||||
},
|
||||
|
||||
seek: (progress) => {
|
||||
const { howl, currentTrack } = get();
|
||||
if (!howl || !currentTrack) return;
|
||||
const time = progress * (howl.duration() || currentTrack.duration);
|
||||
set({ progress, currentTime: time });
|
||||
if (seekDebounce) clearTimeout(seekDebounce);
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
if (gstSeeking) {
|
||||
// GStreamer busy — queue this position; onseek will send it when ready
|
||||
pendingSeekTime = time;
|
||||
return;
|
||||
}
|
||||
gstSeeking = true;
|
||||
const seekTarget = time;
|
||||
armGstWatchdog(() => {
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) { hangRecoveryPos = seekTarget; get().playTrack(ct, q); }
|
||||
});
|
||||
get().howl?.seek(time);
|
||||
}, 100);
|
||||
},
|
||||
|
||||
setVolume: (v) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
get().howl?.volume(clamped);
|
||||
set({ volume: clamped });
|
||||
},
|
||||
|
||||
setProgress: (t, duration) => {
|
||||
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
||||
},
|
||||
|
||||
enqueue: (tracks) => {
|
||||
set(state => {
|
||||
const newQueue = [...state.queue, ...tracks];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
|
||||
clearQueue: () => {
|
||||
get().howl?.unload();
|
||||
clearProgress();
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
// Internal: prefetch next N tracks
|
||||
prefetchUpcoming: (fromIndex: number, queue: Track[]) => {
|
||||
const { prefetched } = get();
|
||||
// Unload and clear old prefetches to prevent memory leaks
|
||||
prefetched.forEach((h, id) => {
|
||||
h.unload();
|
||||
});
|
||||
prefetched.clear();
|
||||
|
||||
const toFetch = queue.slice(fromIndex, fromIndex + 3);
|
||||
toFetch.forEach(track => {
|
||||
const url = buildStreamUrl(track.id);
|
||||
const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false });
|
||||
prefetched.set(track.id, h);
|
||||
});
|
||||
set({ prefetched: new Map(prefetched) });
|
||||
},
|
||||
// Playlist management
|
||||
reorderQueue: (startIndex: number, endIndex: number) => {
|
||||
const { queue, queueIndex, currentTrack } = get();
|
||||
const result = Array.from(queue);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
|
||||
// Update queueIndex if the currently playing track moved
|
||||
let newIndex = queueIndex;
|
||||
if (currentTrack) {
|
||||
newIndex = result.findIndex(t => t.id === currentTrack.id);
|
||||
}
|
||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
removeTrack: (index: number) => {
|
||||
const { queue, queueIndex } = get();
|
||||
const newQueue = [...queue];
|
||||
newQueue.splice(index, 1);
|
||||
// If we removed the currently playing track, stop playback?
|
||||
// Usually wait until it finishes or user skips. We'll just update state.
|
||||
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
|
||||
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
initializeFromServerQueue: async () => {
|
||||
try {
|
||||
const q = await getPlayQueue();
|
||||
if (q.songs.length > 0) {
|
||||
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
|
||||
let currentTrack = mappedTracks[0];
|
||||
let queueIndex = 0;
|
||||
|
||||
if (q.current) {
|
||||
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
||||
if (idx >= 0) {
|
||||
currentTrack = mappedTracks[idx];
|
||||
queueIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
queue: mappedTracks,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
// Convert position from ms to s
|
||||
currentTime: q.position ? q.position / 1000 : 0
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize queue from server', e);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
}), {
|
||||
name: 'psysonic-player',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
volume: state.volume,
|
||||
repeatMode: state.repeatMode,
|
||||
} as Partial<PlayerState>),
|
||||
}));
|
||||
)
|
||||
);
|
||||
|
||||
+298
-52
@@ -23,7 +23,9 @@
|
||||
background-position: center;
|
||||
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
|
||||
transform: scale(1.05);
|
||||
filter: blur(12px);
|
||||
}
|
||||
.hero:hover .hero-bg { filter: blur(8px); }
|
||||
.hero:hover .hero-bg { transform: scale(1); }
|
||||
|
||||
.hero-dots {
|
||||
@@ -183,17 +185,12 @@
|
||||
.artist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-align: center;
|
||||
gap: var(--space-3);
|
||||
height: 100%; /* fill grid row */
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
.artist-card:hover {
|
||||
transform: translateY(-3px);
|
||||
@@ -201,38 +198,49 @@
|
||||
border-color: var(--border);
|
||||
}
|
||||
.artist-card-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.artist-card-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-slow);
|
||||
}
|
||||
.artist-card:hover .artist-card-avatar img { transform: scale(1.04); }
|
||||
.artist-card-avatar-initial {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid;
|
||||
box-shadow: none;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.artist-card-avatar-initial span {
|
||||
font-size: 2.5rem;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-display);
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.artist-card-info {
|
||||
padding: var(--space-3) var(--space-3) var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.artist-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.artist-card-meta {
|
||||
font-size: 12px;
|
||||
@@ -305,24 +313,13 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.random-albums-progress {
|
||||
height: 2px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.random-albums-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
}
|
||||
|
||||
.album-grid .album-card {
|
||||
.album-grid .album-card,
|
||||
.album-grid .artist-card {
|
||||
flex: 0 0 clamp(140px, 15vw, 180px);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
@@ -436,7 +433,8 @@
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: 0;
|
||||
}
|
||||
.search-result-item:hover { background: var(--bg-hover); }
|
||||
.search-result-item:hover,
|
||||
.search-result-item.active { background: var(--bg-hover); }
|
||||
.search-result-icon { width: 32px; height: 32px; border-radius: var(--radius-sm); background: var(--bg-hover); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; }
|
||||
.search-result-thumb { width: 32px; height: 32px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||
.search-result-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
@@ -516,6 +514,14 @@
|
||||
}
|
||||
.album-detail-info { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
|
||||
.album-detail-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; row-gap: var(--space-2); }
|
||||
.album-detail-actions-primary { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.album-detail-content { position: relative; z-index: 1; }
|
||||
.album-detail-badge { margin-bottom: 0.5rem; }
|
||||
.album-detail-back { margin-bottom: 1rem; gap: 6px; }
|
||||
.album-info-dot { margin: 0 4px; }
|
||||
.album-related { padding: 0 var(--space-6) var(--space-8); }
|
||||
.album-related-divider { border-top: 1px solid var(--border-subtle); margin-bottom: 2rem; }
|
||||
.album-related-title { margin-bottom: 1rem; }
|
||||
|
||||
.download-hint {
|
||||
display: flex;
|
||||
@@ -565,10 +571,13 @@
|
||||
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist { padding: 0 var(--space-6) var(--space-6); }
|
||||
.col-center { text-align: center; }
|
||||
|
||||
.tracklist-header {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -579,33 +588,33 @@
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.tracklist-header.tracklist-va {
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.track-row.track-row-va {
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.tracklist-total {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.tracklist-total.tracklist-va {
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
|
||||
}
|
||||
.tracklist-total-label {
|
||||
grid-column: 1 / 5;
|
||||
@@ -618,7 +627,7 @@
|
||||
}
|
||||
.tracklist-total-value {
|
||||
grid-column: 5 / 6;
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -626,15 +635,48 @@
|
||||
}
|
||||
.tracklist-total.tracklist-va .tracklist-total-label { grid-column: 1 / 6; }
|
||||
.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; }
|
||||
.track-row:hover { background: var(--bg-hover); }
|
||||
.track-row.active { background: var(--accent-dim); animation: track-pulse 2.5s ease-in-out infinite; }
|
||||
.track-row:hover,
|
||||
.track-row.context-active { background: var(--bg-hover); }
|
||||
.track-row.active { background: var(--accent-dim); animation: track-pulse 3s ease-in-out infinite; }
|
||||
.track-row.active:hover { background: var(--accent-dim); animation: none; }
|
||||
@keyframes track-pulse {
|
||||
0%, 100% { background: var(--accent-dim); }
|
||||
50% { background: transparent; }
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.track-row > * { padding-top: 6px; padding-bottom: 6px; }
|
||||
|
||||
.track-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Equalizer bars — shown for the currently playing track */
|
||||
.eq-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: 14px;
|
||||
height: 13px;
|
||||
}
|
||||
.eq-bar {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
transform-origin: bottom;
|
||||
transform: scaleY(0.25);
|
||||
animation: eq-bounce 1.1s ease-in-out infinite;
|
||||
}
|
||||
.eq-bar:nth-child(1) { animation-delay: 0s; animation-duration: 1.0s; }
|
||||
.eq-bar:nth-child(2) { animation-delay: 0.18s; animation-duration: 1.3s; }
|
||||
.eq-bar:nth-child(3) { animation-delay: 0.35s; animation-duration: 0.9s; }
|
||||
@keyframes eq-bounce {
|
||||
0%, 100% { transform: scaleY(0.25); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* CD / Disc separator */
|
||||
.disc-header {
|
||||
display: flex;
|
||||
@@ -653,7 +695,7 @@
|
||||
.disc-header:first-child { border-top: none; margin-top: 0; }
|
||||
.disc-icon { font-size: 16px; }
|
||||
|
||||
.track-num { font-size: 13px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.track-num { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.track-info { min-width: 0; }
|
||||
.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; }
|
||||
.track-artist-cell { min-width: 0; display: flex; align-items: flex-start; }
|
||||
@@ -670,7 +712,11 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.track-size { color: var(--ctp-overlay0); }
|
||||
.track-duration { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.track-duration { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-align: center; }
|
||||
.track-meta { display: flex; align-items: center; }
|
||||
.track-meta .track-codec { margin-top: 0; }
|
||||
.track-star-cell { display: flex; justify-content: center; }
|
||||
.track-star-btn { padding: 4px; height: auto; min-height: unset; }
|
||||
|
||||
/* ─ Modal ─ */
|
||||
.modal-overlay {
|
||||
@@ -700,6 +746,7 @@
|
||||
}
|
||||
.modal-close { position: absolute; top: var(--space-4); right: var(--space-4); color: var(--text-muted); }
|
||||
.modal-close:hover { color: var(--text-primary); }
|
||||
.modal-title { margin-bottom: 1rem; font-family: var(--font-display); }
|
||||
|
||||
.artist-bio { font-size: 14px; line-height: 1.7; color: var(--text-secondary); }
|
||||
.artist-bio a { color: var(--accent); text-decoration: underline; }
|
||||
@@ -795,12 +842,12 @@
|
||||
.help-list { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.help-item { border-bottom: 1px solid var(--border-subtle); }
|
||||
.help-item:last-child { border-bottom: none; }
|
||||
.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); transition: background var(--transition-fast); }
|
||||
.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); border-left: 3px solid transparent; transition: background var(--transition-fast), border-color var(--transition-fast); }
|
||||
.help-question:hover { background: var(--bg-hover); }
|
||||
.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); }
|
||||
.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); border-left: 3px solid var(--accent); padding-left: calc(var(--space-5) + 3px); }
|
||||
.help-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform 0.2s ease; }
|
||||
.help-item-open .help-chevron { transform: rotate(180deg); color: var(--accent); }
|
||||
.help-answer { padding: var(--space-3) var(--space-5) var(--space-5); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-hover); border-top: 1px solid var(--border-subtle); }
|
||||
.help-answer { padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-app); border-top: 1px solid var(--border-subtle); border-left: 3px solid var(--accent); }
|
||||
.settings-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
|
||||
.settings-about { display: flex; flex-direction: column; }
|
||||
.settings-about-header { display: flex; align-items: center; gap: var(--space-4); }
|
||||
@@ -1412,6 +1459,12 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
[data-tooltip-wrap]::after {
|
||||
white-space: pre-line;
|
||||
max-width: 220px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Modifiers for position */
|
||||
[data-tooltip][data-tooltip-pos="bottom"]::before {
|
||||
top: 100%;
|
||||
@@ -1425,3 +1478,196 @@
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
|
||||
/* ─ Playlists Page ─ */
|
||||
.playlist-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.playlist-page-header .page-title { margin-bottom: 0; }
|
||||
|
||||
.playlist-filter-input {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
width: 220px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.playlist-filter-input:focus { border-color: var(--accent); }
|
||||
.playlist-filter-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.playlist-list { display: flex; flex-direction: column; }
|
||||
|
||||
.playlist-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr 90px 90px 44px;
|
||||
gap: var(--space-3);
|
||||
padding: 6px var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.playlist-sort-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.playlist-sort-btn:hover,
|
||||
.playlist-sort-btn.active { color: var(--accent); }
|
||||
|
||||
.playlist-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr 90px 90px 44px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: 6px var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.playlist-row:hover { background: var(--bg-hover); }
|
||||
|
||||
.playlist-play-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.playlist-row:hover .playlist-play-icon { opacity: 1; }
|
||||
.playlist-play-icon:hover { transform: scale(1.1); }
|
||||
|
||||
.playlist-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.playlist-delete-btn {
|
||||
opacity: 0;
|
||||
color: var(--ctp-red) !important;
|
||||
padding: 4px;
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
transition: opacity 0.15s;
|
||||
justify-self: center;
|
||||
}
|
||||
.playlist-row:hover .playlist-delete-btn { opacity: 1; }
|
||||
|
||||
/* ─ Statistics Page ─ */
|
||||
.stats-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.stats-overview { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.stats-card-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
.stats-card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Genre chart */
|
||||
.genre-chart {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.genre-row { display: flex; flex-direction: column; gap: 6px; }
|
||||
.genre-row-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.genre-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.genre-counts {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.genre-bar-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.genre-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.8s ease-out;
|
||||
}
|
||||
|
||||
+34
-66
@@ -235,6 +235,7 @@
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
background: var(--bg-app);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
@@ -269,11 +270,10 @@
|
||||
/* ─── Player Bar ─── */
|
||||
.player-bar {
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--bg-player);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
height: var(--player-height);
|
||||
@@ -285,6 +285,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -365,18 +367,11 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-btn {
|
||||
@@ -388,6 +383,7 @@
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-btn:hover {
|
||||
@@ -396,9 +392,14 @@
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.player-btn-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
|
||||
color: var(--ctp-crust);
|
||||
border-radius: 50%;
|
||||
@@ -409,16 +410,22 @@
|
||||
.player-btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.07);
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.player-progress {
|
||||
/* Waveform seekbar section */
|
||||
.player-waveform-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.player-waveform-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-time {
|
||||
@@ -426,62 +433,23 @@
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-progress-bar {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-progress-bar input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
/* Volume section */
|
||||
.player-volume-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
width: 155px;
|
||||
}
|
||||
|
||||
.volume-control svg { flex-shrink: 0; color: var(--text-muted); }
|
||||
|
||||
.volume-control input[type="range"] { flex: 1; }
|
||||
|
||||
/* Connection Toggle */
|
||||
.conn-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
.player-volume-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conn-toggle-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.conn-toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ─── Queue Panel ─── */
|
||||
.queue-panel {
|
||||
|
||||
@@ -662,6 +662,8 @@ select.input.input:focus {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
color: var(--ctp-overlay1);
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.star-rating .star.filled { color: var(--ctp-yellow); }
|
||||
.star-rating .star:hover { color: var(--ctp-yellow); cursor: pointer; }
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
declare module 'butterchurn' {
|
||||
interface Visualizer {
|
||||
connectAudio(audioNode: AudioNode): void;
|
||||
loadPreset(preset: unknown, blendTime: number): void;
|
||||
render(): void;
|
||||
setRendererSize(width: number, height: number): void;
|
||||
}
|
||||
interface CreateOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
pixelRatio?: number;
|
||||
}
|
||||
function createVisualizer(ctx: AudioContext, canvas: HTMLCanvasElement, opts: CreateOptions): Visualizer;
|
||||
export default { createVisualizer };
|
||||
}
|
||||
|
||||
declare module 'butterchurn-presets' {
|
||||
function getPresets(): Record<string, unknown>;
|
||||
export default { getPresets };
|
||||
}
|
||||
+37
-3
@@ -1,10 +1,39 @@
|
||||
const DB_NAME = 'psysonic-img-cache';
|
||||
const STORE_NAME = 'images';
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const MAX_MEMORY_CACHE = 150; // max object URLs kept in RAM
|
||||
const MAX_CONCURRENT_FETCHES = 5;
|
||||
|
||||
// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session)
|
||||
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
|
||||
// Concurrency limiter for network fetches
|
||||
let activeFetches = 0;
|
||||
const fetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquireFetchSlot(): Promise<void> {
|
||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
||||
activeFetches++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => fetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function releaseFetchSlot(): void {
|
||||
activeFetches--;
|
||||
const next = fetchQueue.shift();
|
||||
if (next) { activeFetches++; next(); }
|
||||
}
|
||||
|
||||
function evictIfNeeded(): void {
|
||||
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
|
||||
const oldestKey = objectUrlCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
URL.revokeObjectURL(objectUrlCache.get(oldestKey)!);
|
||||
objectUrlCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
@@ -75,10 +104,12 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictIfNeeded();
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
// 3. Network fetch → store in IDB → return object URL
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL
|
||||
await acquireFetchSlot();
|
||||
try {
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
@@ -86,8 +117,11 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictIfNeeded();
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl; // fallback: direct URL
|
||||
return fetchUrl;
|
||||
} finally {
|
||||
releaseFetchSlot();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user