mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 07:45:45 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 463b7483fd | |||
| 3d11ef91a1 | |||
| c365140870 | |||
| 651b3cb050 | |||
| e2ee9247ad | |||
| 74df7b6b88 | |||
| 27a6693c8c | |||
| a932e7c2db | |||
| 7263d93d42 | |||
| 95283d792b | |||
| 53d5888ebf | |||
| cad4338324 | |||
| f9bc67cb77 | |||
| 74c75d83ca | |||
| 005abae97d | |||
| a9c20dfbdf | |||
| 0b5db172bd | |||
| bf99a64baf | |||
| 523596e414 | |||
| 2fe35e3f9b | |||
| e9fa541933 | |||
| 746aa69405 | |||
| 205b2c1914 | |||
| 0086b3e310 | |||
| 55e7cb835b | |||
| d8da511a8f | |||
| 434ee0ecf1 | |||
| 7d1c66071e | |||
| 1adfda1daa | |||
| e65c476a76 | |||
| 560349819f |
@@ -0,0 +1,12 @@
|
||||
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
|
||||
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
|
||||
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
|
||||
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
|
||||
# bfd is always available via binutils (part of base-devel on Arch).
|
||||
#
|
||||
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
|
||||
# overrides target-specific rustflags here. The PKGBUILD's build() applies
|
||||
# the same fix by patching the RUSTFLAGS env var directly.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "cc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
|
||||
@@ -19,6 +19,12 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
@@ -65,7 +71,7 @@ jobs:
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body,
|
||||
draft: false,
|
||||
draft: true,
|
||||
prerelease: false
|
||||
});
|
||||
return data.id;
|
||||
@@ -74,6 +80,9 @@ jobs:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -91,10 +100,20 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
@@ -106,6 +125,38 @@ jobs:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
- name: sign and upload Windows NSIS updater bundle
|
||||
if: matrix.settings.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
$dir = "src-tauri/target/release/bundle/nsis"
|
||||
$exe = (Get-ChildItem $dir -Filter "*.exe")[0].FullName
|
||||
$zip = $exe -replace '\.exe$', '.nsis.zip'
|
||||
Compress-Archive -Path $exe -DestinationPath $zip -Force
|
||||
npx tauri signer sign --private-key "$env:TAURI_SIGNING_PRIVATE_KEY" --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" $zip
|
||||
gh release upload "app-v$env:VERSION" $zip "$zip.sig" --clobber
|
||||
|
||||
- name: sign and upload macOS bundle signature
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.args }}" == *"aarch64"* ]]; then
|
||||
BUNDLE="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_aarch64.app.tar.gz.sig"
|
||||
else
|
||||
BUNDLE="src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_x64.app.tar.gz.sig"
|
||||
fi
|
||||
npx tauri signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$BUNDLE"
|
||||
cp "${BUNDLE}.sig" "$SIG_NAME"
|
||||
gh release upload "app-v${VERSION}" "$SIG_NAME" --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
@@ -126,9 +177,21 @@ jobs:
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -146,3 +209,32 @@ jobs:
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
+149
@@ -5,6 +5,155 @@ 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.30.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Bulk offline download — Playlists & Artist discographies** *(requested by [@Apollosport](https://github.com/Apollosport), [#54](https://github.com/Psychotoxical/psysonic/issues/54))*: Download an entire playlist or a full artist discography for offline use in one click. Progress is tracked per album on the Artist page ("Caching… 2/5 albums").
|
||||
- **Offline Library filter tabs**: The Offline Library now has four filter tabs — All, Albums, Playlists, and Discographies. The Discographies tab groups albums under their respective artist with section headings.
|
||||
- **Discord Rich Presence** *(requested by [@Bewenben](https://github.com/Bewenben), [#49](https://github.com/Psychotoxical/psysonic/issues/49))* (opt-in): Psysonic can now update your Discord status with the currently playing track, artist, and a live elapsed timer. Toggle in Settings → General → "Discord Rich Presence".
|
||||
- **Artist images on Artists page** *(reported by [@Apollosport](https://github.com/Apollosport), [#53](https://github.com/Psychotoxical/psysonic/issues/53))* (opt-in): Artist avatars on the Artists overview can now show the actual artist image from the server instead of the coloured initial. Toggle in Settings → General → "Show artist images". Off by default to preserve performance on large libraries.
|
||||
- **Image lazy loading**: Cover art and artist images across all pages now load lazily via `IntersectionObserver` (300 px pre-fetch margin), significantly reducing initial page render time on large libraries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crossfade triggers on manual track skip** *(reported by [@netherguy4](https://github.com/netherguy4), [#35](https://github.com/Psychotoxical/psysonic/issues/35))*: Manually clicking Next/Prev or selecting a track from the queue no longer triggers the crossfade transition. Crossfade now only fires on natural track end.
|
||||
- **Playlist offline cache showing individual album cards**: Caching a playlist offline previously created one card per album group in the Offline Library. The playlist is now stored as a single cohesive entry.
|
||||
- **Image cache abort handling**: Aborted image fetches no longer prevented the cached result from being written to IndexedDB, causing covers to reload on every page visit.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Queue tech strip**: Removed genre from the codec/bitrate overlay strip in the Queue panel — genre strings frequently caused layout overflow.
|
||||
- **"Save discography offline" label**: The Artist page offline button now reads "Save discography offline" instead of "Download discography" to avoid confusion with a ZIP export.
|
||||
- **Update toast (Win/Mac)**: The update notification now includes a disclaimer that auto-update is still in development, and always shows a direct GitHub Releases download link alongside the install button as a fallback.
|
||||
- **Facebook theme overhaul**: Improved grey text contrast, opaque album chip and back button, readable Queue/Lyrics tab labels.
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Radio: instant start + background enrichment** *(requested by [@netherguy4](https://github.com/netherguy4))*: Artist Radio now starts immediately from fast local `getTopSongs` results. `getSimilarSongs2` (Last.fm-dependent, slow) continues in the background and silently enriches the queue once it resolves — no waiting before the first song.
|
||||
- **OGG/Vorbis playback** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #42](https://github.com/Psychotoxical/psysonic/pull/42))*: Added `symphonia-format-ogg` — `.ogg` files now play natively without server-side transcoding.
|
||||
- **Click-to-seek in synced lyrics** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Clicking any line in the synced lyrics pane seeks directly to that timestamp.
|
||||
- **Volume scroll wheel** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Scrolling the mouse wheel over the volume slider adjusts volume in ±5 % steps.
|
||||
- **Lyrics visual states** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Synced lyrics lines now show three distinct visual states — active (highlighted), completed (muted), upcoming (neutral).
|
||||
- **Themed audio error toasts** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #43](https://github.com/Psychotoxical/psysonic/pull/43) / [PR #44](https://github.com/Psychotoxical/psysonic/pull/44))*: Unsupported formats and decode failures are now surfaced as themed in-app toast notifications with human-readable messages instead of silent failures.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Hopefully fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
|
||||
- **Radio queue stacking**: Clicking "Start Radio" multiple times no longer appends unlimited duplicate batches — each click replaces the pending Radio section cleanly.
|
||||
- **Start Radio keeps current song playing**: Triggering Radio while a song is playing no longer stops and restarts the current track.
|
||||
- **Radio proactive loading with songs missing `artistId`**: `getSimilarSongs2` results frequently lack `artistId`. A `currentRadioArtistId` module variable now persists the original artist ID as fallback, so proactive loading always fires correctly.
|
||||
- **Seek audio glitch after lyrics click**: Any seek ≥ 100 ms into a track no longer causes a brief fade-from-zero. `EqualPowerFadeIn` now only resets to zero-gain for seeks to the track start.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Infinite Queue: 5 tracks at a time** (was 25): Proactive loading fetches 5 tracks when ≤ 2 remain, keeping the queue lean without interruption.
|
||||
- **Queue section order is now explicit**: Manual tracks → Radio (with `— Radio —` separator) → Infinite Queue auto-added tracks (with `— Auto —` separator). Manually enqueued songs always appear before auto-managed sections.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to [@nisarg-78](https://github.com/nisarg-78) and [@JulianNymark](https://github.com/JulianNymark) for their first contributions in this release.
|
||||
Special thanks to [@netherguy4](https://github.com/netherguy4) for continued feature ideas and feedback.
|
||||
|
||||
---
|
||||
|
||||
## [1.28.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Infinite Queue** *(requested by [@netherguy4](https://github.com/netherguy4))*: When the queue runs out with Repeat off, Psysonic automatically appends 25 random tracks (optionally filtered by the last-played track's genre) so playback never stops. Toggle in Settings → Audio → "Infinite Queue". Auto-added tracks appear below a divider in the Queue panel.
|
||||
- **Start Radio plays immediately** *(requested by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" from the song/queue context menu now starts the seed track instantly while similar and top tracks load in the background — no waiting for the fetch to complete before music plays.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Single-click to play everywhere** *(reported by [@netherguy4](https://github.com/netherguy4))*: Song rows in Album Detail, Playlist Detail, Artist Detail (Top Tracks), Favorites, and Random Mix previously required a double-click. All rows now play on a single click. The track-number cell and the full row are both click targets; buttons and links inside the row still work independently.
|
||||
- **Artist page Play All / Shuffle used Top Tracks only** *(reported by [@smirnoffjr](https://github.com/smirnoffjr))*: "Play All" and "Shuffle" on the Artist detail page only sent the loaded top songs to the queue, not the full discography. Now fetches all albums in parallel and plays songs in chronological album order with correct track-number ordering within each album. Buttons show a spinner while albums are loading.
|
||||
- **Last.fm icon clipped in player bar**: The Last.fm logo button in the player bar was cut off on the right side. Fixed by correcting the SVG `viewBox` from `0 0 24 24` to `0 0 26 22` to match the actual path extents.
|
||||
- **Playlist empty state UX** *(reported by [@netherguy4](https://github.com/netherguy4))*: Empty playlists (on creation, or after deleting all tracks) now show an "Add your first song" CTA button that opens the search panel directly, rather than a plain text message with no action.
|
||||
- **Playlist search rows required "+" button click** *(reported by [@netherguy4](https://github.com/netherguy4))*: Search result rows in the song search panel now add the song on a full-row click — the separate "+" button was redundant and easy to miss.
|
||||
- **Large playlist performance**: Playlists with hundreds of songs would freeze during mouse movement. Root cause: `hoveredSongId` state triggered a full React re-render of every row on every `mouseenter`/`mouseleave` event. Fixed by removing the JS hover state and replacing it with a CSS `.track-row:hover .bulk-check` rule. Also memoized `songs.map(songToTrack)` and the `existingIds` set to avoid recomputation per render. Same fix applied to `AlbumTrackList`.
|
||||
|
||||
---
|
||||
|
||||
## [1.27.4] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **In-App Auto-Update** *(requested by [@netherguy4](https://github.com/netherguy4))*: Psysonic now checks for new releases automatically on startup (3 s delay). On macOS and Windows a native install-and-relaunch flow is available directly in the app — no browser needed. On Linux, a download link to the GitHub release page is shown instead (AppImage is not built due to WebKitGTK incompatibility with Arch/Fedora). The updater uses Tauri's signed updater plugin with minisign signatures verified against a bundled public key.
|
||||
- **Configurable Home Page**: Users can now choose which sections appear on the home page. A new "Home Page" block in Settings → Library lets you toggle each section individually (Featured, Recently Added, Discover, Discover Artists, Recently Played, Personal Favorites, Most Played) with a reset-to-default button. Hidden sections are skipped entirely.
|
||||
- **Consistent icon language** *(requested by [@netherguy4](https://github.com/netherguy4))*: Favorites (local star/heart) now use a filled Heart icon everywhere — Player Bar, Album Detail, Artist Detail, Tracklist, Context Menu. Last.fm love always uses the Last.fm logo. Previously the two were mixed up in several places.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Radio broken from context menu** *(reported by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" in the track and queue-item context menus had no effect. The handler was passing the artist name as the artist ID to `getSimilarSongs2`, which returned an empty result — so no tracks were queued and no error was shown. Now correctly passes `song.artistId`.
|
||||
- **Album Detail hero background not loading**: The blurred album art background in Album Detail only appeared after a track change, never on first visit. Root cause: `buildCoverArtUrl` was called without `useMemo`, generating a new salt on every re-render — causing `useCachedUrl` to cancel and restart its fetch endlessly. Fixed by memoising both the URL and cache key on `album.coverArt`. Same fix applied to Hero and Playlist Detail backgrounds.
|
||||
- **CI: auto-update signing pipeline**: Signing keys were not being passed correctly during the build, and macOS `.sig` files were uploaded with a generic name the manifest generator couldn't match. Fixed the post-build signing step to upload arch-specific names (`Psysonic_aarch64.app.tar.gz.sig`, `Psysonic_x64.app.tar.gz.sig`). First release where the in-app updater is fully functional on macOS and Windows.
|
||||
- **CI: Windows NSIS upload**: The release workflow was not correctly uploading Windows artifacts. Resolved by letting `tauri-action` handle NSIS bundle detection and upload directly — it only searches for what was actually built, so there is no MSI conflict with `--bundles nsis` builds.
|
||||
- **CI: npm + Cargo caching** *(contributed by [@netherguy4](https://github.com/netherguy4))*: Added `actions/cache` for npm and `Swatinem/rust-cache` for Cargo across all build jobs. Warm-cache builds will be significantly faster on subsequent releases.
|
||||
- **Linux/AUR build: ring linker error**: Builds on Arch/CachyOS failed with `rust-lld: undefined symbol: ring_core_*` after the Tauri updater was added. Arch's `rust` package bakes `-fuse-ld=lld` into the default rustflags; ring's C/asm objects are incompatible with lld. Fixed via `.cargo/config.toml` — forces `cc` as linker driver with `-fuse-ld=bfd` to override the hardcoded lld flag. Added `clang` to the AUR `makedepends` (required by ring's bindgen step).
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background flickering in Hero, Album Detail and Playlist Detail**: Blurred hero backgrounds were flickering for up to 20 seconds on first visit. Root cause: `useCachedUrl` with the default `fallbackToFetch = true` immediately returned the raw server URL, causing the background to render twice — once with the HTTP URL (triggering a server fetch) and again when the IndexedDB blob was ready. Fixed by passing `fallbackToFetch = false` in all three locations so the background only renders once the blob is cached.
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Favorite button in Player Bar** *(requested by [@halfkey](https://github.com/halfkey))*: A star icon button now sits next to the Last.fm heart in the player bar. Clicking it toggles the favorite/unfavorite state for the currently playing track with an optimistic UI update — no page reload needed. Uses the same `starredOverrides` mechanism as the album tracklist for instant feedback.
|
||||
- **Bulk Select for song lists**: Multi-select support in Album tracklist and Playlist detail. A checkbox fades in to the left of the track number on hover. Selecting one or more tracks activates the bulk action bar at the top with two actions: **Add to Playlist** (opens the playlist picker submenu) and **Remove from Playlist** (Playlist detail only). Shift-click selects a range; the header checkbox selects / deselects all. CSS uses `color-mix` for the selection highlight, compatible with all 60 themes.
|
||||
- **Song Info modal**: Right-clicking any song and choosing "Song Info" opens a metadata panel fetched live via `getSong`. Displays: title, artist, album, album artist, year, genre, duration, track number; format, bitrate, sample rate, bit depth, channels (Mono / Stereo), file size; file path; and Replay Gain values (track / album gain + peak) when present. Closes with Escape or a click on the backdrop.
|
||||
- **Recently Played section on Home page**: A new "Recently Played" album row appears on the Home page between the hero carousel and the Discover section, powered by the `getAlbumList('recent')` endpoint.
|
||||
- **"Now Playing" visibility toggle in Settings**: New opt-in toggle in Settings → Behavior ("Show activity in Now Playing"). When disabled (default), `reportNowPlaying` is not called, so no activity is reported to the Navidrome "Now Playing" feed. Useful for users who share a server.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Queue cover art not updating**: After a track change the queue panel cover art often stayed on the previous album or took a long time to update. Root cause: `useCachedUrl` and `CachedImage` were not resetting their resolved URL when the `cacheKey` changed. Fixed by resetting `resolved` to `''` before each async cache fetch and basing `CachedImage`'s `loaded` state on `useEffect([cacheKey])` instead of a render-time comparison.
|
||||
- **Fullscreen Player background flickering**: The blurred background briefly showed a blank frame when switching tracks because the new image div was added to the DOM before the blob URL was ready. Fixed in `FsBg` by preloading the image via `new Image()` before inserting the layer, and using `useCachedUrl(..., false)` for the crossfade background so the raw URL is never used as a fallback during transitions.
|
||||
- **Playlist card delete confirmation not visible**: The confirm state only changed the icon colour, which was barely noticeable over the red button. Replaced with a size expansion (24 px → 30 px), an inset white ring, and a pulsing `delete-confirm-pulse` animation that alternates between two shades of red.
|
||||
- **Gruvbox Light Soft — back button and badge**: The album detail back-arrow and album badge were invisible against the warm light background. Added explicit colour overrides for `.album-detail-back` and `.album-detail-badge` in the gruvbox-light-soft theme.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`buildStreamUrl` signature**: Removed the unused `suffix` parameter. Opus transcoding (`format=flac`) is now handled in `playerStore.playTrack` via `track.suffix` check, keeping the URL builder stateless.
|
||||
|
||||
---
|
||||
|
||||
## [1.25.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Single-instance enforcement** *(reported by [@netherguy4](https://github.com/netherguy4))*: Re-launching the app while it was already running (including minimized to tray) would spawn a new independent process, leading to playback conflicts and state divergence. Integrated `tauri-plugin-single-instance` — subsequent launches are intercepted, the existing window is shown, unminimized, and focused instead.
|
||||
|
||||
---
|
||||
|
||||
## [1.25.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **System Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: Functional tray icon with context menu — Play / Pause, Previous Track, Next Track, Show / Hide, and Exit Psysonic. Left-clicking the tray icon toggles window visibility. The tray icon is built via `TrayIconBuilder` in Rust so menu events are properly wired.
|
||||
- **Minimize to Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: New toggle in Settings → Behavior. When enabled, closing the window hides it to the tray instead of exiting. The close button behaviour is intercepted in Rust (`prevent_close` + `window:close-requested` event) and the JS side decides hide vs. exit based on the user setting.
|
||||
- **Sidebar Customization** *(requested by [@lighthous3d](https://github.com/lighthous3d))*: New section in Settings → Appearance. All library and system nav items can be shown/hidden via a toggle switch and reordered by dragging the grip handle. Order and visibility are persisted across sessions (`psysonic_sidebar` in localStorage). Fixed items (Now Playing, Settings) are listed as non-configurable below the list.
|
||||
- **Playlist cover art**: Playlist cards on the Playlists overview page now display the server-generated cover image (Navidrome's `coverArt` field on the playlist object) via the IndexedDB image cache. Falls back to the ListMusic icon when no cover is available.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cover image flickering**: `buildCoverArtUrl()` generates a new random auth salt on every call, causing `useCachedUrl` to re-trigger on every render and produce a rapid re-fetch loop. Fixed by wrapping all `buildCoverArtUrl` / `coverArtCacheKey` calls in `useMemo` with the cover ID as dependency in `ArtistCardLocal`, `QueuePanel`, `FullscreenPlayer`, `Hero`, and `PlaylistDetail`.
|
||||
- **DnD text selection**: Dragging a grip handle in the Sidebar Customizer (and any future `useDragSource` consumer) would select all text on the page during the threshold detection phase. Fixed by calling `e.preventDefault()` in `useDragSource`'s `onMouseDown` handler before the drag threshold is reached.
|
||||
- **Sidebar Customization DnD on Linux**: The initial implementation used the HTML5 Drag & Drop API, which always shows a forbidden cursor on WebKitGTK and does not fire drop events reliably. Rewritten to use the existing psy-drag mouse-event system (`useDragSource` / `psy-drop` custom event), consistent with the Queue panel.
|
||||
|
||||
---
|
||||
|
||||
## [1.24.0] - 2026-03-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,7 +27,9 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll, line highlighting, click-to-seek, and plain-text fallback.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
@@ -37,8 +39,8 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
@@ -52,7 +54,11 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
@@ -70,22 +76,26 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
---
|
||||
|
||||
## ● Known Limitations
|
||||
|
||||
Some known bugs actively working on fixes
|
||||
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
|
||||
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
|
||||
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
|
||||
|
||||
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
|
||||
## 📦 Installation (Arch Linux / AUR)
|
||||
|
||||
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
|
||||
|
||||
| Package | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
|
||||
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
|
||||
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
|
||||
Generated
+22
-2
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.21.0",
|
||||
"version": "1.26.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.21.0",
|
||||
"version": "1.26.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
@@ -1493,6 +1495,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
@@ -1511,6 +1522,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-window-state": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,8 +15,10 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.24.0
|
||||
pkgver=1.30.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -16,6 +16,8 @@ makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
@@ -26,6 +28,20 @@ build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
# because the last -fuse-ld=* flag passed to cc wins.
|
||||
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
|
||||
|
||||
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
|
||||
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
|
||||
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
|
||||
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
|
||||
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
|
||||
unset CFLAGS CXXFLAGS
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
|
||||
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
|
||||
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pull release notes from GitHub
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
Generated
+421
-18
@@ -69,6 +69,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -85,6 +94,18 @@ dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -198,6 +219,24 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io 2.6.0",
|
||||
"async-lock 3.4.2",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener 5.4.1",
|
||||
"futures-lite 2.6.1",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -526,7 +565,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -892,6 +931,17 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -936,6 +986,19 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1059,6 +1122,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1185,6 +1254,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -2361,7 +2441,10 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2500,6 +2583,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2783,6 +2872,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2798,6 +2888,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2947,6 +3049,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2996,7 +3112,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -3176,6 +3292,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
@@ -3361,9 +3483,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.24.0"
|
||||
version = "1.30.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"md5",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
@@ -3376,8 +3499,11 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3514,6 +3640,15 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3631,15 +3766,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3762,12 +3902,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -3777,6 +3930,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -3830,7 +4010,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4231,7 +4411,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4279,8 +4459,8 @@ dependencies = [
|
||||
"pollster",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.44.0",
|
||||
"zbus",
|
||||
"zvariant",
|
||||
"zbus 3.15.2",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4358,6 +4538,7 @@ dependencies = [
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
@@ -4454,6 +4635,18 @@ dependencies = [
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
@@ -4613,6 +4806,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4716,7 +4920,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4806,6 +5010,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -4827,6 +5041,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus 5.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
@@ -4843,6 +5072,39 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.2",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
@@ -4943,7 +5205,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5456,6 +5718,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
@@ -5734,6 +6005,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6524,6 +6804,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
@@ -6569,12 +6859,12 @@ version = "3.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-broadcast 0.5.1",
|
||||
"async-executor",
|
||||
"async-fs",
|
||||
"async-io 1.13.0",
|
||||
"async-lock 2.8.0",
|
||||
"async-process",
|
||||
"async-process 1.8.1",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
@@ -6599,9 +6889,44 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"winapi",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 3.15.2",
|
||||
"zbus_names 2.6.1",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
|
||||
dependencies = [
|
||||
"async-broadcast 0.7.2",
|
||||
"async-executor",
|
||||
"async-io 2.6.0",
|
||||
"async-lock 3.4.2",
|
||||
"async-process 2.5.0",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener 5.4.1",
|
||||
"futures-core",
|
||||
"futures-lite 2.6.1",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid 1.22.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.15",
|
||||
"zbus_macros 5.14.0",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6615,7 +6940,22 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 1.0.109",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.10.0",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6626,7 +6966,18 @@ checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 0.7.15",
|
||||
"zvariant 5.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6709,6 +7060,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
@@ -6726,7 +7089,21 @@ dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
"zvariant_derive 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.15",
|
||||
"zvariant_derive 5.10.0",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6739,7 +7116,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6752,3 +7142,16 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.24.0"
|
||||
version = "1.30.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
@@ -30,10 +31,13 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window"
|
||||
"core:webview:allow-create-webview-window",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6014,6 +6014,36 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
@@ -6254,6 +6284,60 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -6014,6 +6014,36 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
@@ -6254,6 +6284,60 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
+31
-10
@@ -229,9 +229,15 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Restart the fade envelope after seeking (avoids a mid-song click if
|
||||
// the user seeks to the very beginning while a fade was in progress).
|
||||
self.sample_count = 0;
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// For seeks to the very start (< 100 ms): keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
if pos.as_millis() < 100 {
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
@@ -478,13 +484,20 @@ impl SizedDecoder {
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("probe failed: {e}"))?;
|
||||
.map_err(|e| {
|
||||
let hint_str = format_hint.unwrap_or("unknown");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||||
} else {
|
||||
format!("could not open audio stream (.{hint_str}): {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let track = probed.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "no supported audio track".to_string())?;
|
||||
.ok_or_else(|| "no playable audio track found in file".to_string())?;
|
||||
|
||||
let track_id = track.id;
|
||||
let total_duration = track.codec_params.time_base
|
||||
@@ -493,7 +506,13 @@ impl SizedDecoder {
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("codec init failed: {e}"))?;
|
||||
.map_err(|e| {
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
"unsupported codec: no decoder available for this audio format".to_string()
|
||||
} else {
|
||||
format!("failed to initialise audio decoder: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
@@ -505,7 +524,7 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
break decoder.last_decoded();
|
||||
}
|
||||
Err(e) => return Err(format!("first packet: {e}")),
|
||||
Err(e) => return Err(format!("could not read audio data: {e}")),
|
||||
};
|
||||
if packet.track_id() != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
@@ -513,10 +532,10 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
decode_errors += 1;
|
||||
if decode_errors > DECODE_MAX_RETRIES {
|
||||
return Err("too many decode errors".into());
|
||||
return Err("too many decode errors — file may be corrupt".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("decode: {e}")),
|
||||
Err(e) => return Err(format!("audio decode error: {e}")),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1055,6 +1074,7 @@ pub async fn audio_play(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -1118,7 +1138,8 @@ pub async fn audio_play(
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// Discord Rich Presence integration.
|
||||
///
|
||||
/// To enable this feature:
|
||||
/// 1. Go to https://discord.com/developers/applications and create an application.
|
||||
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
|
||||
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
|
||||
/// (use the app icon from public/logo.png).
|
||||
///
|
||||
/// The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
/// so the app always starts cleanly regardless of Discord availability.
|
||||
|
||||
use discord_rich_presence::{
|
||||
activity::{Activity, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState(Mutex::new(None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
|
||||
client.connect().ok()?;
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
|
||||
/// the song/artist without a running timer.
|
||||
#[tauri::command]
|
||||
pub fn discord_update_presence(
|
||||
state: tauri::State<DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
elapsed_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
match try_connect() {
|
||||
Some(client) => *guard = Some(client),
|
||||
None => return Ok(()), // Discord not running — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
let state_text = format!("by {artist}");
|
||||
let large_text = album.as_deref().unwrap_or("Psysonic");
|
||||
|
||||
let assets = Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text);
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.details(&title)
|
||||
.state(&state_text)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
|
||||
// call re-connects.
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+133
-1
@@ -2,11 +2,16 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
mod discord;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
@@ -26,6 +31,50 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Restart after an in-app update.
|
||||
///
|
||||
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
|
||||
/// is still alive, so the single-instance plugin in the new process sees the old
|
||||
/// socket and kills itself immediately (endless loop).
|
||||
///
|
||||
/// This command instead:
|
||||
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
|
||||
/// by then the old process and its single-instance socket are fully gone.
|
||||
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
|
||||
/// prevent_close() and releases the single-instance lock immediately.
|
||||
#[tauri::command]
|
||||
fn relaunch_after_update(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
|
||||
let script = format!(
|
||||
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
|
||||
let bundle = exe
|
||||
.parent() // MacOS/
|
||||
.and_then(|p| p.parent()) // Contents/
|
||||
.and_then(|p| p.parent()); // Psysonic.app
|
||||
if let Some(bundle_path) = bundle {
|
||||
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
|
||||
let _ = std::process::Command::new("sh")
|
||||
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
@@ -292,14 +341,85 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
let window = app.get_webview_window("main").expect("no main window");
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
// ── System tray ───────────────────────────────────────────────
|
||||
{
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
|
||||
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&play_pause)
|
||||
.item(&previous)
|
||||
.item(&next)
|
||||
.item(&sep1)
|
||||
.item(&show_hide)
|
||||
.item(&sep2)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Psysonic")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
|
||||
"next" => { let _ = app.emit("tray:next", ()); }
|
||||
"previous" => { let _ = app.emit("tray:previous", ()); }
|
||||
"show_hide" => {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => { std::process::exit(0); }
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Left-click: toggle window visibility
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event {
|
||||
let app = tray.app_handle();
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
}
|
||||
|
||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||
{
|
||||
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||
@@ -392,6 +512,15 @@ pub fn run() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
api.prevent_close();
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
@@ -411,10 +540,13 @@ pub fn run() {
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_chain_preload,
|
||||
discord::discord_update_presence,
|
||||
discord::discord_clear_presence,
|
||||
lastfm_request,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
relaunch_after_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -29,12 +29,14 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": false,
|
||||
"title": "Psysonic",
|
||||
"tooltip": "Psysonic"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+20
-22
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { showToast } from './utils/toast';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@@ -31,6 +32,7 @@ import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
@@ -43,6 +45,7 @@ import Genres from './pages/Genres';
|
||||
import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
@@ -272,14 +275,16 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Media key event handler
|
||||
// Media key + tray event handler
|
||||
function TauriEventBridge() {
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
@@ -338,9 +343,12 @@ function TauriEventBridge() {
|
||||
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
@@ -372,10 +380,14 @@ function TauriEventBridge() {
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// Close → exit app
|
||||
const win = getCurrentWindow();
|
||||
const u = await win.onCloseRequested(async () => {
|
||||
await invoke('exit_app');
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
const u = await listen('window:close-requested', async () => {
|
||||
if (useAuthStore.getState().minimizeToTray) {
|
||||
await getCurrentWindow().hide();
|
||||
} else {
|
||||
await invoke('exit_app');
|
||||
}
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
@@ -422,20 +434,6 @@ export default function App() {
|
||||
|
||||
const handleExport = async (since: number) => {
|
||||
setExportPickerOpen(false);
|
||||
const showToast = (text: string) => {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = text;
|
||||
toast.style.cssText = `
|
||||
position:fixed; bottom:100px; left:50%; transform:translateX(-50%);
|
||||
background:#24273a; color:#cad3f5; border:1px solid #363a4f;
|
||||
padding:10px 20px; border-radius:10px; font-size:14px;
|
||||
z-index:999999; pointer-events:none;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.5);
|
||||
white-space:nowrap;
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
};
|
||||
try {
|
||||
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
|
||||
const result = await exportNewAlbumsImage(since);
|
||||
|
||||
+14
-3
@@ -74,6 +74,7 @@ export interface SubsonicSong {
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
@@ -96,6 +97,7 @@ export interface SubsonicPlaylist {
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
@@ -393,9 +395,18 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
|
||||
return data.playlist;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
|
||||
if (songIds.length > 0) {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
} else if (prevCount > 0) {
|
||||
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
|
||||
// Use updatePlaylist.view with explicit index removal to clear the list instead.
|
||||
await api('updatePlaylist.view', {
|
||||
playlistId: id,
|
||||
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -143,8 +143,8 @@ export default function AlbumHeader({
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
data-tooltip="Vergrößern"
|
||||
aria-label={`${info.name} Cover vergrößern`}
|
||||
data-tooltip={t('albumDetail.enlargeCover')}
|
||||
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
</button>
|
||||
@@ -203,7 +203,7 @@ export default function AlbumHeader({
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Play, Heart, ListPlus, X } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -67,13 +68,48 @@ export default function AlbumTrackList({
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, globalIdx);
|
||||
const to = Math.max(lastSelectedIdx, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(globalIdx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Close playlist picker on outside click
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
@@ -83,12 +119,51 @@ export default function AlbumTrackList({
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="tracklist">
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`tracklist-header${' tracklist-va'}`}>
|
||||
<div className="col-center">#</div>
|
||||
<div className="col-center">
|
||||
{inSelectMode
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
|
||||
: '#'}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
@@ -105,81 +180,88 @@ export default function AlbumTrackList({
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{discs.get(discNum)!.map((song) => {
|
||||
const globalIdx = songs.indexOf(song);
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
style={{
|
||||
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
|
||||
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
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">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-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(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart 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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, Download, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'available'; version: string; update: Update | null }
|
||||
| { phase: 'downloading'; pct: number }
|
||||
| { phase: 'installing' }
|
||||
| { phase: 'done' };
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<State>({ phase: 'idle' });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
// Try Tauri native updater first (macOS + Windows)
|
||||
const update = await check();
|
||||
if (cancelled) return;
|
||||
if (update) {
|
||||
setState({ phase: 'available', version: update.version, update });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Tauri updater unavailable or network error — fall through to GitHub check
|
||||
}
|
||||
// Fallback: GitHub API check (Linux / offline Tauri updater)
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
|
||||
}
|
||||
} catch {
|
||||
// No update check possible — stay idle
|
||||
}
|
||||
}, 3000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (state.phase !== 'available' || !state.update) return;
|
||||
const update = state.update;
|
||||
const savedVersion = state.version;
|
||||
let total = 0;
|
||||
let downloaded = 0;
|
||||
setState({ phase: 'downloading', pct: 0 });
|
||||
try {
|
||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
|
||||
} else if (event.event === 'Finished') {
|
||||
setState({ phase: 'installing' });
|
||||
}
|
||||
});
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e) {
|
||||
console.error('Update failed', e);
|
||||
setState({ phase: 'available', version: savedVersion, update });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
open('https://github.com/Psychotoxical/psysonic/releases/latest');
|
||||
};
|
||||
|
||||
const version = state.phase === 'available' ? state.version : '';
|
||||
const canInstall = state.phase === 'available' && state.update !== null;
|
||||
const isLinuxFallback = state.phase === 'available' && state.update === null;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
|
||||
|
||||
{state.phase === 'downloading' && (
|
||||
<div className="app-updater-progress-wrap">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{state.pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'installing' && (
|
||||
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{canInstall && (
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
<button className="app-updater-btn-primary" onClick={handleInstall}>
|
||||
<Download size={12} /> {t('common.updaterInstall')}
|
||||
</button>
|
||||
<button className="app-updater-btn-secondary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLinuxFallback && (
|
||||
<button className="app-updater-btn-primary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
@@ -11,14 +11,18 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
// memoized to prevent a new URL on every parent re-render causing refetch loops.
|
||||
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
|
||||
|
||||
return (
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -6,31 +6,56 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
/**
|
||||
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
|
||||
* blob is still resolving — useful for <img> tags so the browser starts
|
||||
* loading immediately. Pass false for CSS background-image consumers that
|
||||
* should only see a stable blob URL (prevents a double crossfade).
|
||||
*/
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
let cancelled = false;
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
const controller = new AbortController();
|
||||
setResolved('');
|
||||
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
||||
if (!controller.signal.aborted) setResolved(url);
|
||||
});
|
||||
return () => { controller.abort(); };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const prevSrc = useRef('');
|
||||
const [inView, setInView] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
if (resolvedSrc !== prevSrc.current) {
|
||||
prevSrc.current = resolvedSrc;
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
||||
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
// URL upgrades within the same image — avoids the end-of-load flash.
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: loaded ? 'opacity 0.15s ease' : 'none' }}
|
||||
ref={imgRef}
|
||||
src={resolvedSrc || undefined}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
@@ -21,7 +22,7 @@ function sanitizeFilename(name: string): string {
|
||||
}
|
||||
|
||||
// ── Add-to-Playlist submenu ───────────────────────────────────────
|
||||
function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
|
||||
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
@@ -84,9 +85,11 @@ function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone:
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
const subStyle: React.CSSProperties = dropDown
|
||||
? { top: 'calc(100% + 4px)', left: 0, right: 'auto' }
|
||||
: flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||
@@ -160,7 +163,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -204,16 +207,73 @@ export default function ContextMenu() {
|
||||
await action();
|
||||
};
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string) => {
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artistId);
|
||||
if (similar.length > 0) {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||
if (seedTrack) {
|
||||
// Start playback immediately based on current state
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack?.id === seedTrack.id) {
|
||||
if (!state.isPlaying) state.resume();
|
||||
// Already playing this track — don't restart
|
||||
} else {
|
||||
playTrack(seedTrack, [seedTrack]);
|
||||
}
|
||||
// Load radio queue in background — enqueueRadio replaces any pending radio
|
||||
// tracks so clicking "Start Radio" again never stacks duplicate batches.
|
||||
try {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedTrack.id)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
}
|
||||
} else {
|
||||
// Artist radio: fire both calls immediately but don't wait for the slow one.
|
||||
// getTopSongs is fast (local library) — start playback as soon as it resolves.
|
||||
// getSimilarSongs2 is slow (Last.fm) — enrich the queue in the background.
|
||||
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
|
||||
try {
|
||||
const top = await getTopSongs(artistName);
|
||||
const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
if (topTracks.length === 0) {
|
||||
// No local top songs — fall back to waiting for similar tracks
|
||||
const similar = await similarPromise;
|
||||
const fallback = similar.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
if (fallback.length === 0) return;
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(fallback, artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(fallback[0], fallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Start playback immediately from top songs
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(topTracks, artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(topTracks[0], topTracks);
|
||||
}
|
||||
// Enrich with similar tracks in the background
|
||||
similarPromise.then(similar => {
|
||||
const similarTracks = similar
|
||||
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
.filter(t => !topTracks.some(top => top.id === t.id));
|
||||
if (similarTracks.length === 0) return;
|
||||
// Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them
|
||||
// together with the new similar tracks rather than losing them.
|
||||
const { queue, queueIndex } = usePlayerStore.getState();
|
||||
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
|
||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,7 +354,7 @@ export default function ContextMenu() {
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
@@ -302,7 +362,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
@@ -315,11 +375,15 @@ export default function ContextMenu() {
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -340,7 +404,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(album.id, !starred);
|
||||
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
@@ -374,7 +438,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(artist.id, !starred);
|
||||
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
</>
|
||||
@@ -415,7 +479,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
@@ -428,14 +492,18 @@ export default function ContextMenu() {
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
|
||||
@@ -62,16 +62,25 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const id = counterRef.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => {
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
}, 20);
|
||||
const t2 = setTimeout(() => {
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Preload the image before starting the crossfade — prevents a blank flash
|
||||
// between the old and new layer while the browser decodes the image.
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,10 +163,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
// useCachedUrl must be called unconditionally (hook rules)
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized
|
||||
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
|
||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
|
||||
// No fetchUrl fallback for the background — we only want stable blob URLs
|
||||
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Fetch artist image for background — fall back to cover art if unavailable
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
@@ -233,11 +245,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
||||
@@ -92,9 +92,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
});
|
||||
}, [album?.id]);
|
||||
|
||||
// Resolve background URL via cache
|
||||
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
|
||||
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
|
||||
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
@@ -102,9 +102,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const stableBgUrl = useRef('');
|
||||
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
|
||||
|
||||
// Resolve cover thumbnail via cache
|
||||
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<svg width={size} height={size} viewBox="0 0 26 22" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
|
||||
const hasSynced = syncedLines !== null && syncedLines.length > 0;
|
||||
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const prevActive = useRef(-1);
|
||||
@@ -103,6 +105,13 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const getLyricLineClass = (i: number, active: number) => {
|
||||
const base = 'lyrics-line';
|
||||
if (i > active) return base;
|
||||
if (i < active) return `${base} completed`;
|
||||
return `${base} active`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
@@ -113,7 +122,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`lyrics-line${i === activeIdx ? ' active' : ''}`}
|
||||
className={getLyricLineClass(i, activeIdx)}
|
||||
onClick={() => { if (duration > 0) seek(line.time / duration); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -35,9 +36,26 @@ export default function PlayerBar() {
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides, setStarredOverride,
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const next = !isStarred;
|
||||
setStarredOverride(currentTrack.id, next);
|
||||
try {
|
||||
if (next) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !next);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
@@ -46,6 +64,12 @@ export default function PlayerBar() {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [setVolume]);
|
||||
|
||||
const handleVolumeWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
setVolume(Math.max(0, Math.min(1, volume + delta)));
|
||||
}, [volume, setVolume]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
@@ -92,6 +116,17 @@ export default function PlayerBar() {
|
||||
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
</div>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-star-btn"
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{currentTrack && lastfmSessionKey && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-love-btn"
|
||||
@@ -100,7 +135,7 @@ export default function PlayerBar() {
|
||||
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Heart size={15} fill={lastfmLoved ? '#e31c23' : 'none'} />
|
||||
<LastfmIcon size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -110,7 +145,7 @@ export default function PlayerBar() {
|
||||
<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')}>
|
||||
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
@@ -121,7 +156,7 @@ export default function PlayerBar() {
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
@@ -174,7 +209,7 @@ export default function PlayerBar() {
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<div className="player-volume-slider-wrap">
|
||||
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
|
||||
{showVolPct && (
|
||||
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
||||
{Math.round(volume * 100)}%
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -159,6 +160,15 @@ export default function QueuePanel() {
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const currentCoverCacheKey = useMemo(
|
||||
() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
@@ -173,9 +183,11 @@ export default function QueuePanel() {
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
@@ -359,19 +371,26 @@ export default function QueuePanel() {
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
|
||||
{(currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
<div className="queue-current-tech">
|
||||
{[
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
const bd = currentTrack.bitDepth;
|
||||
const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
|
||||
if (bd && sr) return `${bd}/${sr}`;
|
||||
if (bd) return `${bd}-bit`;
|
||||
if (sr) return sr;
|
||||
return undefined;
|
||||
})(),
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="queue-current-track-body">
|
||||
<div className="queue-current-cover">
|
||||
{currentTrack.coverArt ? (
|
||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
|
||||
<img src={currentCoverSrc} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
@@ -470,6 +489,14 @@ export default function QueuePanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<ArrowUpToLine size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
@@ -482,6 +509,8 @@ export default function QueuePanel() {
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||
@@ -495,8 +524,18 @@ export default function QueuePanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${track.id}-${idx}`}>
|
||||
{isFirstRadioAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isFirstAutoAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
@@ -537,6 +576,7 @@ export default function QueuePanel() {
|
||||
{formatTime(track.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
+41
-96
@@ -1,66 +1,33 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags, ListMusic
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
|
||||
const navItems = [
|
||||
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
|
||||
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' },
|
||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
|
||||
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
||||
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' },
|
||||
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
|
||||
];
|
||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||
// Exported so Settings can render the same item metadata.
|
||||
export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey: string; to: string; section: 'library' | 'system' }> = {
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
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;
|
||||
if (lMin !== cMin) return lMin > cMin;
|
||||
return lPat > cPat;
|
||||
}
|
||||
|
||||
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="update-toast-icon" style={{ marginTop: 'auto' }} data-tooltip={`${t('sidebar.updateAvailable')}: ${latestVersion}`} data-tooltip-pos="bottom">
|
||||
<ArrowUpCircle size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="update-toast">
|
||||
<div className="update-toast-header">
|
||||
<ArrowUpCircle size={14} />
|
||||
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
|
||||
</div>
|
||||
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
|
||||
<button
|
||||
className="update-toast-link"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
|
||||
>
|
||||
{t('sidebar.updateLink')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
@@ -77,30 +44,15 @@ export default function Sidebar({
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
const visibleSystem = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
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.replace(/^v/i, ''));
|
||||
}
|
||||
} catch {
|
||||
// network unavailable — silently skip
|
||||
}
|
||||
};
|
||||
|
||||
const initial = setTimeout(check, 1500);
|
||||
const interval = setInterval(check, 10 * 60 * 1000); // every 10 minutes
|
||||
|
||||
return () => { cancelled = true; clearTimeout(initial); clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
@@ -122,7 +74,7 @@ export default function Sidebar({
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Hauptnavigation">
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
|
||||
{navItems.map(item => (
|
||||
{visibleLibrary.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -136,7 +88,7 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Now Playing — special styled */}
|
||||
{/* Now Playing — fixed, always visible */}
|
||||
<NavLink
|
||||
to="/now-playing"
|
||||
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||
@@ -163,26 +115,19 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/help"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.help') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<HelpCircle size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.help')}</span>}
|
||||
</NavLink>
|
||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystem.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string | null {
|
||||
if (!bytes) return null;
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
|
||||
return `${(bytes / 1_000).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (value === null || value === undefined || value === '' || value === '—') return null;
|
||||
return (
|
||||
<tr>
|
||||
<td className="song-info-label">{label}</td>
|
||||
<td className="song-info-value">{value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
|
||||
}
|
||||
|
||||
export default function SongInfoModal() {
|
||||
const { t } = useTranslation();
|
||||
const { songInfoModal, closeSongInfo } = usePlayerStore();
|
||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
||||
setSong(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getSong(songInfoModal.songId).then(s => {
|
||||
if (!cancelled) { setSong(s); setLoading(false); }
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [songInfoModal.isOpen, songInfoModal.songId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!songInfoModal.isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSongInfo(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [songInfoModal.isOpen, closeSongInfo]);
|
||||
|
||||
if (!songInfoModal.isOpen) return null;
|
||||
|
||||
const channels = song?.channelCount === 1
|
||||
? t('songInfo.mono')
|
||||
: song?.channelCount === 2
|
||||
? t('songInfo.stereo')
|
||||
: song?.channelCount
|
||||
? `${song.channelCount} ch`
|
||||
: null;
|
||||
|
||||
const trackLabel = song?.discNumber && song.discNumber > 1
|
||||
? `${song.discNumber} – ${song.track}`
|
||||
: song?.track != null
|
||||
? String(song.track)
|
||||
: null;
|
||||
|
||||
const hasReplayGain = song?.replayGain &&
|
||||
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="song-info-backdrop" onClick={closeSongInfo} />
|
||||
<div className="song-info-modal" role="dialog" aria-modal="true" aria-label={t('songInfo.title')}>
|
||||
<div className="song-info-header">
|
||||
<span className="song-info-title">{t('songInfo.title')}</span>
|
||||
<button className="btn btn-ghost song-info-close" onClick={closeSongInfo} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="song-info-body">
|
||||
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
|
||||
|
||||
{!loading && song && (
|
||||
<table className="song-info-table">
|
||||
<tbody>
|
||||
<Row label={t('songInfo.songTitle')} value={song.title} />
|
||||
<Row label={t('songInfo.artist')} value={song.artist} />
|
||||
<Row label={t('songInfo.album')} value={song.album} />
|
||||
{song.albumArtist && song.albumArtist !== song.artist && (
|
||||
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
|
||||
)}
|
||||
<Row label={t('songInfo.year')} value={song.year} />
|
||||
<Row label={t('songInfo.genre')} value={song.genre} />
|
||||
<Row label={t('songInfo.duration')} value={formatDuration(song.duration)} />
|
||||
<Row label={t('songInfo.track')} value={trackLabel} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
|
||||
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
|
||||
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
|
||||
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
|
||||
<Row label={t('songInfo.channels')} value={channels} />
|
||||
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
|
||||
|
||||
{song.path && (
|
||||
<>
|
||||
<Divider />
|
||||
<Row label={t('songInfo.path')} value={<span className="song-info-path">{song.path}</span>} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasReplayGain && (
|
||||
<>
|
||||
<Divider />
|
||||
{song.replayGain!.trackGain !== undefined && (
|
||||
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
|
||||
)}
|
||||
{song.replayGain!.albumGain !== undefined && (
|
||||
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
|
||||
)}
|
||||
{song.replayGain!.trackPeak !== undefined && (
|
||||
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,9 @@ export function useDragSource(getPayload: () => DragPayload) {
|
||||
(e: React.MouseEvent) => {
|
||||
// Only left-click
|
||||
if (e.button !== 0) return;
|
||||
// Prevent the browser from starting a text-selection drag during the
|
||||
// threshold detection phase (mousedown → mousemove before startDrag).
|
||||
e.preventDefault();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
+351
@@ -28,9 +28,11 @@ const enTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
starred: 'Personal Favorites',
|
||||
recent: 'Recently Added',
|
||||
mostPlayed: 'Most Played',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
discover: 'Discover',
|
||||
loadMore: 'Load More',
|
||||
discoverMore: 'Discover More',
|
||||
@@ -102,6 +104,7 @@ const enTranslation = {
|
||||
goToArtist: 'Go to Artist',
|
||||
download: 'Download (ZIP)',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
songInfo: 'Song Info',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Back',
|
||||
@@ -137,6 +140,7 @@ const enTranslation = {
|
||||
bioModal: 'Artist Biography',
|
||||
bioClose: 'Close',
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
@@ -162,6 +166,9 @@ const enTranslation = {
|
||||
openedInBrowser: 'Opened in browser',
|
||||
featuredOn: 'Also Featured On',
|
||||
similarArtists: 'Similar Artists',
|
||||
cacheOffline: 'Save discography offline',
|
||||
offlineCached: 'Discography cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -234,6 +241,8 @@ const enTranslation = {
|
||||
all: 'All',
|
||||
gridView: 'Grid view',
|
||||
listView: 'List view',
|
||||
imagesOn: 'Artist images on — may increase network and system load',
|
||||
imagesOff: 'Artist images off — showing initials only',
|
||||
loadMore: 'Load more',
|
||||
notFound: 'No artists found.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
@@ -272,6 +281,10 @@ const enTranslation = {
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'All',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
@@ -300,6 +313,17 @@ const enTranslation = {
|
||||
filterSearchGenres: 'Search genres…',
|
||||
filterNoGenres: 'No genres match',
|
||||
filterClear: 'Clear',
|
||||
bulkSelected: '{{count}} selected',
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||
bulkClear: 'Clear selection',
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is ready',
|
||||
updaterInstall: 'Install & Restart',
|
||||
updaterDownloading: 'Downloading…',
|
||||
updaterInstalling: 'Installing…',
|
||||
updaterDownload: 'Download from GitHub',
|
||||
updaterExperimentalHint: 'Auto-update is still in development',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -361,6 +385,14 @@ const enTranslation = {
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
downloadsTitle: 'Download Folder',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
@@ -377,6 +409,7 @@ const enTranslation = {
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Contributors',
|
||||
aboutSpecialThanksLabel: 'Special Thanks',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
|
||||
@@ -390,6 +423,11 @@ const enTranslation = {
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
tabShortcuts: 'Shortcuts',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
@@ -422,6 +460,8 @@ const enTranslation = {
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
},
|
||||
changelog: {
|
||||
@@ -525,6 +565,9 @@ const enTranslation = {
|
||||
shuffle: 'Shuffle queue',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
autoAdded: '— Added automatically —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
@@ -594,6 +637,29 @@ const enTranslation = {
|
||||
lyricsLoading: 'Loading lyrics…',
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
songTitle: 'Title',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumArtist: 'Album Artist',
|
||||
year: 'Year',
|
||||
genre: 'Genre',
|
||||
duration: 'Duration',
|
||||
track: 'Track',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Sample Rate',
|
||||
bitDepth: 'Bit Depth',
|
||||
channels: 'Channels',
|
||||
fileSize: 'File Size',
|
||||
path: 'Path',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'New Playlist',
|
||||
@@ -603,9 +669,11 @@ const enTranslation = {
|
||||
cancel: 'Cancel',
|
||||
empty: 'No playlists yet.',
|
||||
emptyPlaylist: 'This playlist is empty.',
|
||||
addFirstSong: 'Add your first song',
|
||||
notFound: 'Playlist not found.',
|
||||
songs: '{{n}} songs',
|
||||
playAll: 'Play All',
|
||||
shuffle: 'Shuffle',
|
||||
addToQueue: 'Add to Queue',
|
||||
back: 'Back to Playlists',
|
||||
deletePlaylist: 'Delete',
|
||||
@@ -619,6 +687,9 @@ const enTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
addSong: 'Add to playlist',
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -649,9 +720,11 @@ const deTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
starred: 'Persönliche Favoriten',
|
||||
recent: 'Zuletzt hinzugefügt',
|
||||
mostPlayed: 'Meistgehört',
|
||||
recentlyPlayed: 'Kürzlich gespielt',
|
||||
discover: 'Entdecken',
|
||||
loadMore: 'Mehr laden',
|
||||
discoverMore: 'Mehr entdecken',
|
||||
@@ -723,6 +796,7 @@ const deTranslation = {
|
||||
goToArtist: 'Zum Künstler',
|
||||
download: 'Herunterladen (ZIP)',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
songInfo: 'Song-Infos',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -758,6 +832,7 @@ const deTranslation = {
|
||||
bioModal: 'Künstler-Biografie',
|
||||
bioClose: 'Schließen',
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -783,6 +858,9 @@ const deTranslation = {
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
featuredOn: 'Auch enthalten auf',
|
||||
similarArtists: 'Ähnliche Künstler',
|
||||
cacheOffline: 'Diskografie offline speichern',
|
||||
offlineCached: 'Diskografie gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -855,6 +933,8 @@ const deTranslation = {
|
||||
all: 'Alle',
|
||||
gridView: 'Gitteransicht',
|
||||
listView: 'Listenansicht',
|
||||
imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen',
|
||||
imagesOff: 'Künstlerbilder deaktiviert — zeigt nur Initialen',
|
||||
loadMore: 'Mehr laden',
|
||||
notFound: 'Keine Künstler gefunden.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
@@ -893,6 +973,10 @@ const deTranslation = {
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
offlineAlbumCount_plural: '{{n}} Alben',
|
||||
offlineFilterAll: 'Alle',
|
||||
offlineFilterAlbums: 'Alben',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
@@ -921,6 +1005,17 @@ const deTranslation = {
|
||||
filterSearchGenres: 'Genres suchen…',
|
||||
filterNoGenres: 'Keine Genres gefunden',
|
||||
filterClear: 'Zurücksetzen',
|
||||
bulkSelected: '{{count}} ausgewählt',
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||
bulkClear: 'Auswahl aufheben',
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} ist bereit',
|
||||
updaterInstall: 'Installieren & Neustart',
|
||||
updaterDownloading: 'Wird geladen…',
|
||||
updaterInstalling: 'Wird installiert…',
|
||||
updaterDownload: 'Von GitHub herunterladen',
|
||||
updaterExperimentalHint: 'Automatische Updates sind noch in Entwicklung',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -982,6 +1077,14 @@ const deTranslation = {
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
@@ -998,6 +1101,7 @@ const deTranslation = {
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Mitwirkende',
|
||||
aboutSpecialThanksLabel: 'Besonderer Dank',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
|
||||
@@ -1011,6 +1115,11 @@ const deTranslation = {
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
tabShortcuts: 'Tastenkürzel',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
@@ -1043,6 +1152,8 @@ const deTranslation = {
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
},
|
||||
changelog: {
|
||||
@@ -1146,6 +1257,9 @@ const deTranslation = {
|
||||
shuffle: 'Warteschlange mischen',
|
||||
gapless: 'Nahtlos',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
autoAdded: '— Automatisch hinzugefügt —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
@@ -1215,6 +1329,29 @@ const deTranslation = {
|
||||
lyricsLoading: 'Lyrics werden geladen…',
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
songTitle: 'Titel',
|
||||
artist: 'Künstler',
|
||||
album: 'Album',
|
||||
albumArtist: 'Album-Künstler',
|
||||
year: 'Jahr',
|
||||
genre: 'Genre',
|
||||
duration: 'Länge',
|
||||
track: 'Track',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Abtastrate',
|
||||
bitDepth: 'Bittiefe',
|
||||
channels: 'Kanäle',
|
||||
fileSize: 'Dateigröße',
|
||||
path: 'Speicherort',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Neue Playlist',
|
||||
@@ -1224,9 +1361,11 @@ const deTranslation = {
|
||||
cancel: 'Abbrechen',
|
||||
empty: 'Noch keine Playlists.',
|
||||
emptyPlaylist: 'Diese Playlist ist leer.',
|
||||
addFirstSong: 'Ersten Song hinzufügen',
|
||||
notFound: 'Playlist nicht gefunden.',
|
||||
songs: '{{n}} Songs',
|
||||
playAll: 'Alle abspielen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
addToQueue: 'Zur Warteschlange',
|
||||
back: 'Zurück zu Playlists',
|
||||
deletePlaylist: 'Löschen',
|
||||
@@ -1240,6 +1379,9 @@ const deTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
addSong: 'Zur Playlist hinzufügen',
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1270,9 +1412,11 @@ const frTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
starred: 'Favoris personnels',
|
||||
recent: 'Ajoutés récemment',
|
||||
mostPlayed: 'Les plus écoutés',
|
||||
recentlyPlayed: 'Récemment écoutés',
|
||||
discover: 'Découvrir',
|
||||
loadMore: 'Charger plus',
|
||||
discoverMore: 'Découvrir plus',
|
||||
@@ -1344,6 +1488,7 @@ const frTranslation = {
|
||||
goToArtist: 'Aller à l\'artiste',
|
||||
download: 'Télécharger (ZIP)',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
songInfo: 'Infos du morceau',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Retour',
|
||||
@@ -1379,6 +1524,7 @@ const frTranslation = {
|
||||
bioModal: 'Biographie de l\'artiste',
|
||||
bioClose: 'Fermer',
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
@@ -1404,6 +1550,9 @@ const frTranslation = {
|
||||
openedInBrowser: 'Ouvert dans le navigateur',
|
||||
featuredOn: 'Également sur',
|
||||
similarArtists: 'Artistes similaires',
|
||||
cacheOffline: 'Enregistrer la discographie hors ligne',
|
||||
offlineCached: 'Discographie en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoris',
|
||||
@@ -1476,6 +1625,8 @@ const frTranslation = {
|
||||
all: 'Tous',
|
||||
gridView: 'Vue en grille',
|
||||
listView: 'Vue en liste',
|
||||
imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système',
|
||||
imagesOff: 'Images d\'artistes désactivées — affichage des initiales uniquement',
|
||||
loadMore: 'Charger plus',
|
||||
notFound: 'Aucun artiste trouvé.',
|
||||
albumCount_one: '{{count}} album',
|
||||
@@ -1514,6 +1665,10 @@ const frTranslation = {
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Tout',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
@@ -1542,6 +1697,17 @@ const frTranslation = {
|
||||
filterSearchGenres: 'Rechercher des genres…',
|
||||
filterNoGenres: 'Aucun genre trouvé',
|
||||
filterClear: 'Effacer',
|
||||
bulkSelected: '{{count}} sélectionné(s)',
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||
bulkClear: 'Désélectionner',
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est prête',
|
||||
updaterInstall: 'Installer & Redémarrer',
|
||||
updaterDownloading: 'Téléchargement…',
|
||||
updaterInstalling: 'Installation…',
|
||||
updaterDownload: 'Télécharger depuis GitHub',
|
||||
updaterExperimentalHint: 'La mise à jour automatique est encore en développement',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -1603,6 +1769,14 @@ const frTranslation = {
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
downloadsTitle: 'Dossier de téléchargement',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
pickFolder: 'Sélectionner',
|
||||
@@ -1619,6 +1793,7 @@ const frTranslation = {
|
||||
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
||||
aboutContributorsLabel: 'Contributeurs',
|
||||
aboutSpecialThanksLabel: 'Remerciements spéciaux',
|
||||
changelog: 'Journal des modifications',
|
||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
|
||||
@@ -1632,6 +1807,11 @@ const frTranslation = {
|
||||
tabPlayback: 'Lecture',
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabAppearance: 'Apparence',
|
||||
homeCustomizerTitle: 'Page d\'accueil',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
tabShortcuts: 'Raccourcis',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
@@ -1664,6 +1844,8 @@ const frTranslation = {
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
},
|
||||
changelog: {
|
||||
@@ -1767,6 +1949,9 @@ const frTranslation = {
|
||||
shuffle: 'Mélanger la file',
|
||||
gapless: 'Sans blanc',
|
||||
crossfade: 'Fondu',
|
||||
infiniteQueue: 'File infinie',
|
||||
autoAdded: '— Ajouté automatiquement —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Masquer',
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
@@ -1836,6 +2021,29 @@ const frTranslation = {
|
||||
lyricsLoading: 'Chargement des paroles…',
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
songTitle: 'Titre',
|
||||
artist: 'Artiste',
|
||||
album: 'Album',
|
||||
albumArtist: 'Artiste de l\'album',
|
||||
year: 'Année',
|
||||
genre: 'Genre',
|
||||
duration: 'Durée',
|
||||
track: 'Piste',
|
||||
format: 'Format',
|
||||
bitrate: 'Débit',
|
||||
sampleRate: 'Fréquence d\'échantillonnage',
|
||||
bitDepth: 'Profondeur de bits',
|
||||
channels: 'Canaux',
|
||||
fileSize: 'Taille du fichier',
|
||||
path: 'Emplacement',
|
||||
replayGainTrack: 'RG Gain de piste',
|
||||
replayGainAlbum: 'RG Gain d\'album',
|
||||
replayGainPeak: 'RG Crête de piste',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stéréo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Nouvelle playlist',
|
||||
@@ -1845,9 +2053,11 @@ const frTranslation = {
|
||||
cancel: 'Annuler',
|
||||
empty: 'Aucune playlist pour l\'instant.',
|
||||
emptyPlaylist: 'Cette playlist est vide.',
|
||||
addFirstSong: 'Ajouter votre premier titre',
|
||||
notFound: 'Playlist introuvable.',
|
||||
songs: '{{n}} titres',
|
||||
playAll: 'Tout lire',
|
||||
shuffle: 'Aléatoire',
|
||||
addToQueue: 'Ajouter à la file',
|
||||
back: 'Retour aux playlists',
|
||||
deletePlaylist: 'Supprimer',
|
||||
@@ -1861,6 +2071,9 @@ const frTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
addSong: 'Ajouter à la playlist',
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1891,9 +2104,11 @@ const nlTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
starred: 'Persoonlijke favorieten',
|
||||
recent: 'Recent toegevoegd',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
recentlyPlayed: 'Recent afgespeeld',
|
||||
discover: 'Ontdekken',
|
||||
loadMore: 'Meer laden',
|
||||
discoverMore: 'Meer ontdekken',
|
||||
@@ -1965,6 +2180,7 @@ const nlTranslation = {
|
||||
goToArtist: 'Naar artiest',
|
||||
download: 'Downloaden (ZIP)',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
songInfo: 'Nummerinfo',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Terug',
|
||||
@@ -2000,6 +2216,7 @@ const nlTranslation = {
|
||||
bioModal: 'Artiest biografie',
|
||||
bioClose: 'Sluiten',
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
@@ -2025,6 +2242,9 @@ const nlTranslation = {
|
||||
openedInBrowser: 'Geopend in browser',
|
||||
featuredOn: 'Ook te vinden op',
|
||||
similarArtists: 'Vergelijkbare artiesten',
|
||||
cacheOffline: 'Discografie offline opslaan',
|
||||
offlineCached: 'Discografie gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorieten',
|
||||
@@ -2097,6 +2317,8 @@ const nlTranslation = {
|
||||
all: 'Alle',
|
||||
gridView: 'Rasterweergave',
|
||||
listView: 'Lijstweergave',
|
||||
imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen',
|
||||
imagesOff: 'Artiestafbeeldingen uit — toont alleen initialen',
|
||||
loadMore: 'Meer laden',
|
||||
notFound: 'Geen artiesten gevonden.',
|
||||
albumCount_one: '{{count}} album',
|
||||
@@ -2135,6 +2357,10 @@ const nlTranslation = {
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Alles',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Afspeellijsten',
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
@@ -2163,6 +2389,17 @@ const nlTranslation = {
|
||||
filterSearchGenres: 'Genres zoeken…',
|
||||
filterNoGenres: 'Geen genres gevonden',
|
||||
filterClear: 'Wissen',
|
||||
bulkSelected: '{{count}} geselecteerd',
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||
bulkClear: 'Selectie wissen',
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} is klaar',
|
||||
updaterInstall: 'Installeren & Herstarten',
|
||||
updaterDownloading: 'Downloaden…',
|
||||
updaterInstalling: 'Installeren…',
|
||||
updaterDownload: 'Downloaden van GitHub',
|
||||
updaterExperimentalHint: 'Automatisch bijwerken is nog in ontwikkeling',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -2224,6 +2461,14 @@ const nlTranslation = {
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
downloadsTitle: 'Downloadmap',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
pickFolder: 'Selecteren',
|
||||
@@ -2240,6 +2485,7 @@ const nlTranslation = {
|
||||
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
||||
aboutContributorsLabel: 'Bijdragers',
|
||||
aboutSpecialThanksLabel: 'Speciale dank',
|
||||
changelog: 'Wijzigingslog',
|
||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
|
||||
@@ -2253,6 +2499,11 @@ const nlTranslation = {
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAppearance: 'Weergave',
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
tabShortcuts: 'Sneltoetsen',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
@@ -2285,6 +2536,8 @@ const nlTranslation = {
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
},
|
||||
changelog: {
|
||||
@@ -2388,6 +2641,9 @@ const nlTranslation = {
|
||||
shuffle: 'Wachtrij shufflen',
|
||||
gapless: 'Naadloos',
|
||||
crossfade: 'Overgang',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
autoAdded: '— Automatisch toegevoegd —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
nextTracks: 'Volgende nummers',
|
||||
@@ -2457,6 +2713,29 @@ const nlTranslation = {
|
||||
lyricsLoading: 'Songtekst laden…',
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
songTitle: 'Titel',
|
||||
artist: 'Artiest',
|
||||
album: 'Album',
|
||||
albumArtist: 'Albumartiest',
|
||||
year: 'Jaar',
|
||||
genre: 'Genre',
|
||||
duration: 'Duur',
|
||||
track: 'Track',
|
||||
format: 'Formaat',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Samplefrequentie',
|
||||
bitDepth: 'Bitdiepte',
|
||||
channels: 'Kanalen',
|
||||
fileSize: 'Bestandsgrootte',
|
||||
path: 'Locatie',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Nieuwe playlist',
|
||||
@@ -2466,9 +2745,11 @@ const nlTranslation = {
|
||||
cancel: 'Annuleren',
|
||||
empty: 'Nog geen playlists.',
|
||||
emptyPlaylist: 'Deze playlist is leeg.',
|
||||
addFirstSong: 'Voeg je eerste nummer toe',
|
||||
notFound: 'Playlist niet gevonden.',
|
||||
songs: '{{n}} nummers',
|
||||
playAll: 'Alles afspelen',
|
||||
shuffle: 'Willekeurig',
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
back: 'Terug naar playlists',
|
||||
deletePlaylist: 'Verwijderen',
|
||||
@@ -2482,6 +2763,9 @@ const nlTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
addSong: 'Toevoegen aan playlist',
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2512,9 +2796,11 @@ const zhTranslation = {
|
||||
playlists: '播放列表',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
starred: '个人收藏',
|
||||
recent: '最近添加',
|
||||
mostPlayed: '最常播放',
|
||||
recentlyPlayed: '最近播放',
|
||||
discover: '发现',
|
||||
loadMore: '加载更多',
|
||||
discoverMore: '发现更多',
|
||||
@@ -2586,6 +2872,7 @@ const zhTranslation = {
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
back: '返回',
|
||||
@@ -2621,6 +2908,7 @@ const zhTranslation = {
|
||||
bioModal: '艺术家简介',
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
@@ -2646,6 +2934,9 @@ const zhTranslation = {
|
||||
openedInBrowser: '已在浏览器中打开',
|
||||
featuredOn: '还出现在',
|
||||
similarArtists: '相似艺术家',
|
||||
cacheOffline: '离线保存全部专辑',
|
||||
offlineCached: '全部专辑已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
},
|
||||
favorites: {
|
||||
title: '收藏夹',
|
||||
@@ -2718,6 +3009,8 @@ const zhTranslation = {
|
||||
all: '全部',
|
||||
gridView: '网格视图',
|
||||
listView: '列表视图',
|
||||
imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载',
|
||||
imagesOff: '艺术家图片已关闭 — 仅显示首字母',
|
||||
loadMore: '加载更多',
|
||||
notFound: '未找到艺术家。',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
@@ -2784,6 +3077,17 @@ const zhTranslation = {
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已就绪',
|
||||
updaterInstall: '安装并重启',
|
||||
updaterDownloading: '下载中…',
|
||||
updaterInstalling: '安装中…',
|
||||
updaterDownload: '从 GitHub 下载',
|
||||
updaterExperimentalHint: '自动更新功能仍在开发中',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -2845,6 +3149,14 @@ const zhTranslation = {
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
pickFolder: '选择',
|
||||
@@ -2861,6 +3173,7 @@ const zhTranslation = {
|
||||
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
||||
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
||||
aboutContributorsLabel: '贡献者',
|
||||
aboutSpecialThanksLabel: '特别感谢',
|
||||
changelog: '更新日志',
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
|
||||
@@ -2874,6 +3187,11 @@ const zhTranslation = {
|
||||
tabPlayback: '播放',
|
||||
tabLibrary: '音乐库',
|
||||
tabAppearance: '外观',
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
tabShortcuts: '快捷键',
|
||||
tabServer: '服务器',
|
||||
tabAbout: '关于',
|
||||
@@ -2906,6 +3224,8 @@ const zhTranslation = {
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
},
|
||||
changelog: {
|
||||
@@ -3009,6 +3329,9 @@ const zhTranslation = {
|
||||
shuffle: '随机打乱队列',
|
||||
gapless: '无缝播放',
|
||||
crossfade: '交叉淡入淡出',
|
||||
infiniteQueue: '无限队列',
|
||||
autoAdded: '— 自动添加 —',
|
||||
radioAdded: '— 收音机 —',
|
||||
hide: '隐藏',
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
@@ -3078,6 +3401,29 @@ const zhTranslation = {
|
||||
lyricsLoading: '正在加载歌词…',
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
songTitle: '标题',
|
||||
artist: '艺术家',
|
||||
album: '专辑',
|
||||
albumArtist: '专辑艺术家',
|
||||
year: '年份',
|
||||
genre: '流派',
|
||||
duration: '时长',
|
||||
track: '曲目',
|
||||
format: '格式',
|
||||
bitrate: '比特率',
|
||||
sampleRate: '采样率',
|
||||
bitDepth: '位深度',
|
||||
channels: '声道',
|
||||
fileSize: '文件大小',
|
||||
path: '文件路径',
|
||||
replayGainTrack: 'RG 曲目增益',
|
||||
replayGainAlbum: 'RG 专辑增益',
|
||||
replayGainPeak: 'RG 曲目峰值',
|
||||
mono: '单声道',
|
||||
stereo: '立体声',
|
||||
},
|
||||
playlists: {
|
||||
title: '播放列表',
|
||||
newPlaylist: '新建播放列表',
|
||||
@@ -3087,9 +3433,11 @@ const zhTranslation = {
|
||||
cancel: '取消',
|
||||
empty: '暂无播放列表。',
|
||||
emptyPlaylist: '此播放列表为空。',
|
||||
addFirstSong: '添加第一首歌曲',
|
||||
notFound: '未找到播放列表。',
|
||||
songs: '{{n}} 首歌曲',
|
||||
playAll: '全部播放',
|
||||
shuffle: '随机播放',
|
||||
addToQueue: '添加到队列',
|
||||
back: '返回播放列表',
|
||||
deletePlaylist: '删除',
|
||||
@@ -3103,6 +3451,9 @@ const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
@@ -32,6 +32,7 @@ export default function AlbumDetail() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -112,9 +113,15 @@ const handleEnqueueAll = () => {
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const track = songToTrack(song);
|
||||
if (!track.genre && album?.album.genre) track.genre = album.album.genre;
|
||||
playTrack(track, [track]);
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
|
||||
playTrack(track, tracks);
|
||||
};
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
@@ -195,12 +202,14 @@ const handleEnqueueAll = () => {
|
||||
const next = new Set(starredSongs);
|
||||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||||
setStarredSongs(next);
|
||||
setStarredOverride(song.id, !wasStarred);
|
||||
try {
|
||||
if (wasStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
setStarredOverride(song.id, wasStarred);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,9 +234,12 @@ const handleEnqueueAll = () => {
|
||||
deleteAlbum(album.album.id, serverId);
|
||||
};
|
||||
|
||||
// Hooks must be called unconditionally — derive from nullable album state
|
||||
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
|
||||
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
|
||||
// Hooks must be called unconditionally — derive from nullable album state.
|
||||
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
||||
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
|
||||
// which cancels and restarts the useCachedUrl effect → background never resolves.
|
||||
const coverUrl = useMemo(() => album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
|
||||
+98
-25
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
@@ -46,6 +48,7 @@ export default function ArtistDetail() {
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
@@ -57,6 +60,10 @@ export default function ArtistDetail() {
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const clearQueue = usePlayerStore(state => state.clearQueue);
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||
const { downloadArtist, bulkProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -160,18 +167,34 @@ export default function ArtistDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (topSongs.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(topSongs[0], topSongs);
|
||||
const fetchAllTracks = async () => {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
};
|
||||
|
||||
const handlePlayAll = async () => {
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks();
|
||||
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
if (topSongs.length > 0) {
|
||||
const shuffled = [...topSongs].sort(() => Math.random() - 0.5);
|
||||
clearQueue();
|
||||
playTrack(shuffled[0], shuffled);
|
||||
const handleShuffle = async () => {
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks();
|
||||
if (tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,16 +202,33 @@ export default function ArtistDetail() {
|
||||
if (!artist) return;
|
||||
setRadioLoading(true);
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artist.id, 50);
|
||||
if (similar.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(similar[0], similar);
|
||||
// Fire both fetches in parallel
|
||||
const topPromise = getTopSongs(artist.name);
|
||||
const similarPromise = getSimilarSongs2(artist.id, 50);
|
||||
|
||||
// Start playing as soon as top songs arrive
|
||||
const top = await topPromise;
|
||||
if (top.length > 0) {
|
||||
const firstTrack = songToTrack(top[0]);
|
||||
playTrack(firstTrack, [firstTrack]);
|
||||
setRadioLoading(false);
|
||||
// Enqueue remaining tracks when similar songs arrive
|
||||
const similar = await similarPromise;
|
||||
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
||||
if (remaining.length > 0) enqueue(remaining);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
// No top songs — fall back to similar
|
||||
const similar = await similarPromise;
|
||||
if (similar.length > 0) {
|
||||
const tracks = similar.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
}
|
||||
setRadioLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Radio start failed', e);
|
||||
} finally {
|
||||
setRadioLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -283,19 +323,21 @@ export default function ArtistDetail() {
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{topSongs.length > 0 && (
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll}>
|
||||
<Play size={16} /> {t('artistDetail.playAll')}
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={handleShuffle}>
|
||||
<Shuffle size={16} /> {t('artistDetail.shuffle')}
|
||||
<button className="btn btn-surface" onClick={handleShuffle} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{t('artistDetail.shuffle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -303,6 +345,28 @@ export default function ArtistDetail() {
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
|
||||
</button>
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,13 +401,22 @@ export default function ArtistDetail() {
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, topSongs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
<CachedImage
|
||||
|
||||
+98
-55
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist } from '../api/subsonic';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ALL_SENTINEL = 'ALL';
|
||||
@@ -23,12 +25,52 @@ function nameColor(name: string): string {
|
||||
}
|
||||
|
||||
function nameInitial(name: string): string {
|
||||
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
|
||||
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
|
||||
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
|
||||
const letter = name.match(/\p{L}/u)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
// Fallback: first alphanumeric (e.g. "1349")
|
||||
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
|
||||
return alnum?.toUpperCase() ?? '?';
|
||||
const alnum = name.match(/[0-9]/)?.[0];
|
||||
return alnum ?? '?';
|
||||
}
|
||||
|
||||
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && artist.coverArt) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 300)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
|
||||
alt={artist.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && artist.coverArt) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 64)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Artists() {
|
||||
@@ -42,6 +84,8 @@ export default function Artists() {
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
@@ -101,6 +145,15 @@ export default function Artists() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
@@ -146,30 +199,25 @@ export default function Artists() {
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(artist => {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,31 +227,26 @@ export default function Artists() {
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 className="letter-heading">{letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{groups[letter].map(artist => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+21
-8
@@ -17,10 +17,15 @@ export default function Favorites() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
function removeSong(id: string) {
|
||||
unstar(id, 'song').catch(() => {});
|
||||
setStarredOverride(id, false);
|
||||
setSongs(prev => prev.filter(s => s.id !== id));
|
||||
}
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
@@ -45,7 +50,8 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0;
|
||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
@@ -65,14 +71,14 @@ export default function Favorites() {
|
||||
<AlbumRow title={t('favorites.albums')} albums={albums} />
|
||||
)}
|
||||
|
||||
{songs.length > 0 && (
|
||||
{visibleSongs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}
|
||||
>
|
||||
@@ -82,7 +88,7 @@ export default function Favorites() {
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
@@ -98,14 +104,17 @@ export default function Favorites() {
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div />
|
||||
</div>
|
||||
{songs.map((song, i) => {
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
@@ -124,8 +133,12 @@ export default function Favorites() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
|
||||
{i + 1}
|
||||
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
|
||||
+45
-24
@@ -4,13 +4,18 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
|
||||
export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
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 [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -20,13 +25,15 @@ export default function Home() {
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([s, n, r, f, artists]) => {
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
@@ -39,7 +46,7 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent',
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
currentList: SubsonicAlbum[],
|
||||
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
|
||||
) => {
|
||||
@@ -57,7 +64,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Hero albums={heroAlbums} />
|
||||
{isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
@@ -66,19 +73,23 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
/>
|
||||
{randomArtists.length > 0 && (
|
||||
{isVisible('recent') && (
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
{isVisible('discover') && (
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
/>
|
||||
)}
|
||||
{isVisible('discoverArtists') && randomArtists.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
|
||||
@@ -96,7 +107,15 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{starred.length > 0 && (
|
||||
{isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.recentlyPlayed')}
|
||||
albums={recentlyPlayed}
|
||||
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
{isVisible('starred') && starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
albums={starred}
|
||||
@@ -104,12 +123,14 @@ export default function Home() {
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
{isVisible('mostPlayed') && (
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, Star, ExternalLink, MicVocal } from 'lucide-react';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import {
|
||||
@@ -296,7 +296,7 @@ export default function NowPlaying() {
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
<Heart size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
<button
|
||||
className="np-star-btn"
|
||||
|
||||
+129
-69
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -7,6 +7,8 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
|
||||
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
||||
|
||||
export default function OfflineLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -15,26 +17,36 @@ export default function OfflineLibrary() {
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
const countByType = (type: FilterType) => {
|
||||
if (type === 'all') return albums.length;
|
||||
return albums.filter(a => (a.type ?? 'album') === type).length;
|
||||
};
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? albums
|
||||
: albums.filter(a => (a.type ?? 'album') === filter);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (albumId: string) => {
|
||||
const tracks = buildTracks(albumId);
|
||||
@@ -45,6 +57,84 @@ const buildTracks = (albumId: string) => {
|
||||
enqueue(buildTracks(albumId));
|
||||
};
|
||||
|
||||
const renderCard = (album: typeof albums[0]) => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={`${album.serverId}:${album.id}`} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
data-tooltip={t('queue.addToQueue')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// For artist filter: group by artist name
|
||||
const renderArtistGroups = () => {
|
||||
const groups: Record<string, typeof albums> = {};
|
||||
for (const album of filtered) {
|
||||
const key = album.artist || '—';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(album);
|
||||
}
|
||||
const sortedArtists = Object.keys(groups).sort((a, b) => a.localeCompare(b));
|
||||
return sortedArtists.map(artistName => (
|
||||
<div key={artistName} className="offline-artist-group">
|
||||
<h2 className="offline-artist-group-heading">{artistName}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{groups[artistName].map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const TABS: { id: FilterType; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'connection.offlineFilterAll' },
|
||||
{ id: 'album', labelKey: 'connection.offlineFilterAlbums' },
|
||||
{ id: 'playlist', labelKey: 'connection.offlineFilterPlaylists' },
|
||||
{ id: 'artist', labelKey: 'connection.offlineFilterArtists' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="offline-library animate-fade-in">
|
||||
<div className="offline-library-header">
|
||||
@@ -57,60 +147,30 @@ const buildTracks = (albumId: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<div className="offline-filter-tabs">
|
||||
{TABS.map(tab => {
|
||||
const count = countByType(tab.id);
|
||||
if (tab.id !== 'all' && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`offline-filter-tab${filter === tab.id ? ' active' : ''}`}
|
||||
onClick={() => setFilter(tab.id)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
<span className="offline-filter-tab-count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
|
||||
) : filter === 'artist' ? (
|
||||
renderArtistGroups()
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(album => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={album.id} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
title="Zur Warteschlange hinzufügen"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+210
-68
@@ -1,12 +1,15 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw } from 'lucide-react';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, search, setRating, star, unstar,
|
||||
getSimilarSongs2, SubsonicPlaylist, SubsonicSong,
|
||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
@@ -53,9 +56,11 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const { startDrag, isDragging } = useDragDrop();
|
||||
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
|
||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
@@ -63,11 +68,50 @@ export default function PlaylistDetail() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
|
||||
|
||||
const toggleSelect = (id: string, idx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, idx);
|
||||
const to = Math.max(lastSelectedIdx, idx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(idx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
const bulkRemove = () => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter(s => !selectedIds.has(s.id));
|
||||
setSongs(next);
|
||||
savePlaylist(next, prevCount);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBulkPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showBulkPlPicker]);
|
||||
|
||||
// ── 2×2 cover quad (first 4 unique album covers) ─────────────
|
||||
const coverQuad = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
@@ -82,9 +126,18 @@ export default function PlaylistDetail() {
|
||||
return result;
|
||||
}, [songs]);
|
||||
|
||||
// One resolved URL for the blurred background (must be called unconditionally)
|
||||
// useMemo is required here — buildCoverArtUrl generates a new salt on every call,
|
||||
// which would change bgFetchUrl every render and cause useCachedUrl to re-fetch in a loop.
|
||||
// Stable fetch URLs + cache keys for the 2×2 grid and blurred background.
|
||||
// buildCoverArtUrl generates a new crypto salt on every call, so these MUST
|
||||
// be memoized — otherwise every render produces new URLs, useCachedUrl
|
||||
// re-triggers, state updates, another render → infinite flicker loop.
|
||||
const coverQuadUrls = useMemo(() =>
|
||||
Array.from({ length: 4 }, (_, i) => {
|
||||
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
|
||||
if (!coverId) return null;
|
||||
return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) };
|
||||
}),
|
||||
[coverQuad]);
|
||||
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
|
||||
@@ -131,15 +184,21 @@ export default function PlaylistDetail() {
|
||||
|
||||
// ── Suggestions ───────────────────────────────────────────────
|
||||
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
|
||||
const withArtist = currentSongs.filter(s => s.artistId);
|
||||
if (!withArtist.length) return;
|
||||
const pick = withArtist[Math.floor(Math.random() * withArtist.length)];
|
||||
if (!currentSongs.length) return;
|
||||
// Count genres across playlist songs, pick the most common one
|
||||
const genreCounts: Record<string, number> = {};
|
||||
for (const s of currentSongs) {
|
||||
if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1;
|
||||
}
|
||||
const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]);
|
||||
// Fall back to no genre filter if none of the songs have genre tags
|
||||
const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined;
|
||||
const existingIds = new Set(currentSongs.map(s => s.id));
|
||||
setLoadingSuggestions(true);
|
||||
setSuggestions([]);
|
||||
try {
|
||||
const similar = await getSimilarSongs2(pick.artistId!, 25);
|
||||
setSuggestions(similar.filter(s => !existingIds.has(s.id)).slice(0, 10));
|
||||
const random = await getRandomSongs(25, genre);
|
||||
setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10));
|
||||
} catch {}
|
||||
setLoadingSuggestions(false);
|
||||
}, []);
|
||||
@@ -150,11 +209,11 @@ export default function PlaylistDetail() {
|
||||
}, [playlist?.id]);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id));
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
|
||||
if (id) touchPlaylist(id);
|
||||
} catch {}
|
||||
setSaving(false);
|
||||
@@ -162,9 +221,10 @@ export default function PlaylistDetail() {
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter((_, i) => i !== idx);
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
savePlaylist(next, prevCount);
|
||||
};
|
||||
|
||||
// ── Add ───────────────────────────────────────────────────────
|
||||
@@ -185,12 +245,13 @@ export default function PlaylistDetail() {
|
||||
|
||||
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const isStarred = starredSongs.has(song.id);
|
||||
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
setStarredSongs(prev => {
|
||||
const next = new Set(prev);
|
||||
isStarred ? next.delete(song.id) : next.add(song.id);
|
||||
return next;
|
||||
});
|
||||
setStarredOverride(song.id, !isStarred);
|
||||
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -277,6 +338,10 @@ export default function PlaylistDetail() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// ── Memoized derivations ──────────────────────────────────────
|
||||
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
|
||||
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
|
||||
|
||||
// ── Drag-over visual feedback ─────────────────────────────────
|
||||
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
@@ -298,8 +363,6 @@ export default function PlaylistDetail() {
|
||||
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
|
||||
}
|
||||
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
|
||||
@@ -318,21 +381,11 @@ export default function PlaylistDetail() {
|
||||
<div className="album-detail-hero">
|
||||
{/* 2×2 cover grid */}
|
||||
<div className="playlist-cover-grid">
|
||||
{Array.from({ length: 4 }, (_, i) => {
|
||||
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
|
||||
if (!coverId) {
|
||||
return <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />;
|
||||
}
|
||||
return (
|
||||
<CachedImage
|
||||
key={i}
|
||||
className="playlist-cover-cell"
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
cacheKey={coverArtCacheKey(coverId, 200)}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="album-detail-meta">
|
||||
@@ -348,7 +401,6 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
||||
@@ -356,7 +408,19 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(songs.map(songToTrack));
|
||||
const shuffled = [...tracks];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}}>
|
||||
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
|
||||
</button>
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(tracks);
|
||||
}}>
|
||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
||||
</button>
|
||||
@@ -367,6 +431,25 @@ export default function PlaylistDetail() {
|
||||
>
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
{songs.length > 0 && id && (() => {
|
||||
const isDownloading = isAlbumDownloading(id);
|
||||
const isCached = isAlbumDownloaded(id, activeServerId);
|
||||
const progress = isDownloading ? getAlbumProgress(id) : null;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={isDownloading}
|
||||
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
|
||||
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,20 +478,13 @@ export default function PlaylistDetail() {
|
||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||
)}
|
||||
{searchResults.map(song => (
|
||||
<div key={song.id} className="playlist-search-row">
|
||||
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||
<div className="playlist-search-info">
|
||||
<span className="playlist-search-title">{song.title}</span>
|
||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||
</div>
|
||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||
<button
|
||||
className="playlist-search-add-btn"
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
onClick={() => addSong(song)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -417,9 +493,53 @@ export default function PlaylistDetail() {
|
||||
{/* ── Tracklist ── */}
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowBulkPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showBulkPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={bulkRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('common.bulkRemoveFromPlaylist')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist">
|
||||
<div className="col-center">#</div>
|
||||
<div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
|
||||
{selectedIds.size > 0
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
|
||||
: '#'}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
@@ -430,7 +550,13 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
|
||||
{songs.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('playlists.emptyPlaylist')}</div>
|
||||
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<span>{t('playlists.emptyPlaylist')}</span>
|
||||
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
|
||||
<Search size={15} />
|
||||
{t('playlists.addFirstSong')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{songs.map((song, idx) => (
|
||||
@@ -442,13 +568,16 @@ export default function PlaylistDetail() {
|
||||
|
||||
<div
|
||||
data-track-idx={idx}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onMouseEnter={e => handleRowMouseEnter(idx, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||
onDoubleClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[idx], tracks);
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (selectedIds.size > 0) {
|
||||
toggleSelect(song.id, idx, e.shiftKey);
|
||||
} else {
|
||||
playTrack(tracks[idx], tracks);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
@@ -456,20 +585,30 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
{/* # — play on click, grip icon on hover */}
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
|
||||
onClick={() => { const tracks = songs.map(songToTrack); playTrack(tracks[idx], tracks); }}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <GripVertical size={13} />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: idx + 1}
|
||||
</div>
|
||||
{/* # — checkbox in select mode, always-visible play button otherwise */}
|
||||
{(() => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title */}
|
||||
<div className="track-info">
|
||||
@@ -486,9 +625,9 @@ export default function PlaylistDetail() {
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => handleToggleStar(song, e)}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -570,7 +709,10 @@ export default function PlaylistDetail() {
|
||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onDoubleClick={() => addSong(song)}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addSong(song);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
|
||||
+17
-7
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, X } from 'lucide-react';
|
||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist } from '../api/subsonic';
|
||||
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
|
||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -135,11 +136,20 @@ export default function Playlists() {
|
||||
onClick={() => navigate(`/playlists/${pl.id}`)}
|
||||
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
|
||||
>
|
||||
{/* Cover area — playlist SVG placeholder */}
|
||||
{/* Cover area — server collage or fallback icon */}
|
||||
<div className="album-card-cover">
|
||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||
<ListMusic size={48} strokeWidth={1.2} />
|
||||
</div>
|
||||
{pl.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(pl.coverArt, 256)}
|
||||
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
|
||||
alt={pl.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||
<ListMusic size={48} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay — same pattern as AlbumCard */}
|
||||
<div className="album-card-play-overlay">
|
||||
@@ -162,7 +172,7 @@ export default function Playlists() {
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={12} />
|
||||
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+145
-140
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
@@ -28,6 +28,10 @@ export default function RandomMix() {
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const psyDrag = useDragDrop();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
@@ -105,11 +109,12 @@ export default function RandomMix() {
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
setStarredOverride(song.id, !currentlyStarred);
|
||||
|
||||
try {
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
@@ -117,6 +122,7 @@ export default function RandomMix() {
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
setStarredOverride(song.id, currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,19 +325,28 @@ export default function RandomMix() {
|
||||
<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 className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
{genreMixSongs.map(song => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = genreMixSongs.map(songToTrack);
|
||||
const isCurrentTrack = currentTrack?.id === song.id;
|
||||
const artist = song.artist;
|
||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const isArtistJustAdded = addedArtist === artist;
|
||||
return (
|
||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -349,21 +364,51 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
|
||||
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{isCurrentTrack && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell">
|
||||
{artist ? (
|
||||
<button
|
||||
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isArtistBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||
setAddedArtist(artist);
|
||||
setTimeout(() => setAddedArtist(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>{artist}</button>
|
||||
) : <span className="track-artist">—</span>}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => toggleSongStar(song, e)}
|
||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedGenre && (loading && songs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -371,26 +416,37 @@ export default function RandomMix() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<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' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div 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>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
|
||||
{filteredSongs.map((song) => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = filteredSongs.map(songToTrack);
|
||||
const isCurrentTrack = currentTrack?.id === song.id;
|
||||
const artist = song.artist;
|
||||
const genre = song.genre;
|
||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const isArtistJustAdded = addedArtist === artist;
|
||||
const isGenreBlocked = !!genre && (
|
||||
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
|
||||
);
|
||||
const isGenreJustAdded = addedGenre === genre;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
@@ -413,124 +469,73 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
|
||||
data-tooltip={t('randomMix.play')}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{isCurrentTrack && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-artist-cell">
|
||||
{(() => {
|
||||
const artist = song.artist;
|
||||
if (!artist) return <span className="track-artist">—</span>;
|
||||
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const justAdded = addedArtist === artist;
|
||||
return (
|
||||
<div className="track-artist-cell">
|
||||
{artist ? (
|
||||
<button
|
||||
className="btn btn-ghost track-artist"
|
||||
style={{
|
||||
fontSize: 'inherit',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
|
||||
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
cursor: isBlocked ? 'default' : 'pointer',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 'auto',
|
||||
minHeight: 'unset',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBlocked) return;
|
||||
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
if (!already) {
|
||||
if (isArtistBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||
setAddedArtist(artist);
|
||||
setTimeout(() => setAddedArtist(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>
|
||||
{artist}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>{artist}</button>
|
||||
) : <span className="track-artist">—</span>}
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{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 (
|
||||
<div>
|
||||
{genre ? (
|
||||
<button
|
||||
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isGenreBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||
setAddedGenre(genre);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isGenreBlocked ? t('randomMix.genreBlocked') : isGenreJustAdded ? t('randomMix.genreAddedToBlacklist') : t('randomMix.genreClickHint')}
|
||||
>{genre}</button>
|
||||
) : <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
|
||||
<div className="track-star-cell">
|
||||
<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}
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => toggleSongStar(song, e)}
|
||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
{genre}
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.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 className="track-duration">{formatDuration(song.duration)}</div>
|
||||
</div>
|
||||
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
+312
-30
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
@@ -19,6 +20,10 @@ import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -50,6 +55,31 @@ const CONTRIBUTORS = [
|
||||
'songToTrack() — unified track construction across all sources',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'nisarg-78',
|
||||
since: '1.29.0',
|
||||
contributions: [
|
||||
'Click-to-seek in synced lyrics (PR #38)',
|
||||
'Volume scroll wheel on volume slider (PR #38)',
|
||||
'Lyrics line visual states: active / completed / upcoming (PR #38)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'JulianNymark',
|
||||
since: '1.29.0',
|
||||
contributions: [
|
||||
'OGG/Vorbis container support via symphonia-format-ogg (PR #42)',
|
||||
'Themed toast notifications for audio playback errors (PR #43)',
|
||||
'Human-readable audio error messages (PR #44)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SPECIAL_THANKS = [
|
||||
{
|
||||
github: 'netherguy4',
|
||||
reason: 'Countless constructive feature ideas and thoughtful feedback',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
@@ -409,6 +439,8 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -558,6 +590,8 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -603,43 +637,34 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{([
|
||||
{ id: 'inter', label: 'Inter', sample: 'The quick brown fox' },
|
||||
{ id: 'outfit', label: 'Outfit', sample: 'The quick brown fox' },
|
||||
{ id: 'dm-sans', label: 'DM Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'nunito', label: 'Nunito', sample: 'The quick brown fox' },
|
||||
{ id: 'rubik', label: 'Rubik', sample: 'The quick brown fox' },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', sample: 'The quick brown fox' },
|
||||
{ id: 'figtree', label: 'Figtree', sample: 'The quick brown fox' },
|
||||
{ id: 'manrope', label: 'Manrope', sample: 'The quick brown fox' },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'lexend', label: 'Lexend', sample: 'The quick brown fox' },
|
||||
] as { id: FontId; label: string; sample: string }[]).map(f => (
|
||||
{(
|
||||
[
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
background: fontStore.font === f.id ? 'var(--accent-dim)' : 'transparent',
|
||||
border: `1px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--radius-md)', padding: '10px 14px',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: fontStore.font === f.id ? 'var(--accent)' : 'transparent',
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{f.label}</div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{f.sample}</div>
|
||||
</div>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SidebarCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -972,6 +997,50 @@ export default function Settings() {
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
@@ -1090,6 +1159,28 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0, paddingTop: 2, fontSize: 13 }}>{t('settings.aboutSpecialThanksLabel')}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', flex: 1 }}>
|
||||
{SPECIAL_THANKS.map(s => (
|
||||
<div key={s.github} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<img
|
||||
src={`https://github.com/${s.github}.png?size=32`}
|
||||
width={22} height={22}
|
||||
style={{ borderRadius: '50%', flexShrink: 0 }}
|
||||
alt={s.github}
|
||||
/>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
|
||||
onClick={() => openUrl(`https://github.com/${s.github}`)}
|
||||
>
|
||||
@{s.github}
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>— {s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -1132,6 +1223,197 @@ function renderInline(text: string): React.ReactNode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function HomeCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const { sections, toggleSection, reset } = useHomeStore();
|
||||
|
||||
const SECTION_LABELS: Record<HomeSectionId, string> = {
|
||||
hero: t('home.hero'),
|
||||
recent: t('home.recent'),
|
||||
discover: t('home.discover'),
|
||||
discoverArtists: t('home.discoverArtists'),
|
||||
recentlyPlayed: t('home.recentlyPlayed'),
|
||||
starred: t('home.starred'),
|
||||
mostPlayed: t('home.mostPlayed'),
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<LayoutGrid size={18} />
|
||||
<h2>{t('settings.homeCustomizerTitle')}</h2>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.sidebarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
{sections.map(sec => (
|
||||
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
|
||||
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
||||
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { onMouseDown } = useDragSource(() => ({
|
||||
data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }),
|
||||
label,
|
||||
}));
|
||||
return (
|
||||
<span
|
||||
className="sidebar-customizer-grip"
|
||||
data-tooltip={t('settings.sidebarDrag')}
|
||||
data-tooltip-pos="right"
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
||||
|
||||
function SidebarCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const { items, setItems, toggleItem, reset } = useSidebarStore();
|
||||
const { isDragging: isPsyDragging } = useDragDrop();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
|
||||
const dropTargetRef = useRef<DropTarget>(null);
|
||||
const itemsRef = useRef(items);
|
||||
itemsRef.current = items;
|
||||
|
||||
const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library');
|
||||
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: { type?: string; index?: number; section?: string };
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return;
|
||||
|
||||
const fromIdx = parsed.index;
|
||||
const fromSection = parsed.section as 'library' | 'system';
|
||||
const target = dropTargetRef.current;
|
||||
dropTargetRef.current = null; setDropTarget(null);
|
||||
if (!target || target.section !== fromSection) return;
|
||||
|
||||
const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems];
|
||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||
|
||||
const [moved] = sectionItems.splice(fromIdx, 1);
|
||||
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||
|
||||
// Merge reordered section back into flat items array
|
||||
const all = [...itemsRef.current];
|
||||
const positions = all.map((cfg, i) => ({ cfg, i }))
|
||||
.filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection)
|
||||
.map(({ i }) => i);
|
||||
positions.forEach((pos, i) => { all[pos] = sectionItems[i]; });
|
||||
setItems(all);
|
||||
};
|
||||
el.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [libraryItems, systemItems, setItems]);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isPsyDragging || !containerRef.current) return;
|
||||
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
|
||||
let target: DropTarget = null;
|
||||
for (const row of rows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const idx = Number(row.dataset.sidebarIdx);
|
||||
const section = row.dataset.sidebarSection as 'library' | 'system';
|
||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; }
|
||||
target = { idx, before: false, section };
|
||||
}
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
|
||||
const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => {
|
||||
const meta = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!meta) return null;
|
||||
const Icon = meta.icon;
|
||||
const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before;
|
||||
const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before;
|
||||
return (
|
||||
<div
|
||||
key={cfg.id}
|
||||
data-sidebar-idx={localIdx}
|
||||
data-sidebar-section={section}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<SidebarGripHandle idx={localIdx} section={section} label={t(meta.labelKey)} />
|
||||
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
|
||||
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
|
||||
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<PanelLeft size={18} />
|
||||
<h2>{t('settings.sidebarTitle')}</h2>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.sidebarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Library block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||||
</div>
|
||||
{/* System block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||||
<div className="sidebar-customizer-fixed-hint">
|
||||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogSection() {
|
||||
const { t } = useTranslation();
|
||||
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
|
||||
|
||||
@@ -33,6 +33,11 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
infiniteQueueEnabled: boolean;
|
||||
showArtistImages: boolean;
|
||||
minimizeToTray: boolean;
|
||||
discordRichPresence: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
|
||||
@@ -64,6 +69,11 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setDiscordRichPresence: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
logout: () => void;
|
||||
@@ -96,6 +106,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
infiniteQueueEnabled: false,
|
||||
showArtistImages: false,
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
nowPlayingEnabled: false,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
isLoggedIn: false,
|
||||
@@ -160,6 +175,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
|
||||
|
||||
export interface HomeSectionConfig {
|
||||
id: HomeSectionId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
|
||||
{ id: 'hero', visible: true },
|
||||
{ id: 'recent', visible: true },
|
||||
{ id: 'discover', visible: true },
|
||||
{ id: 'discoverArtists', visible: true },
|
||||
{ id: 'recentlyPlayed', visible: true },
|
||||
{ id: 'starred', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
];
|
||||
|
||||
interface HomeStore {
|
||||
sections: HomeSectionConfig[];
|
||||
toggleSection: (id: HomeSectionId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useHomeStore = create<HomeStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sections: DEFAULT_HOME_SECTIONS,
|
||||
toggleSection: (id) => set((s) => ({
|
||||
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
|
||||
})),
|
||||
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
|
||||
}),
|
||||
{ name: 'psysonic_home' }
|
||||
)
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
@@ -33,6 +33,7 @@ export interface OfflineAlbumMeta {
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
trackIds: string[];
|
||||
type?: 'album' | 'playlist' | 'artist';
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
@@ -49,6 +50,8 @@ interface OfflineState {
|
||||
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
|
||||
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
|
||||
jobs: DownloadJob[];
|
||||
/** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */
|
||||
bulkProgress: Record<string, { done: number; total: number }>;
|
||||
|
||||
isDownloaded: (trackId: string, serverId: string) => boolean;
|
||||
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
|
||||
@@ -62,7 +65,10 @@ interface OfflineState {
|
||||
year: number | undefined,
|
||||
songs: SubsonicSong[],
|
||||
serverId: string,
|
||||
type?: 'album' | 'playlist' | 'artist',
|
||||
) => Promise<void>;
|
||||
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
|
||||
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
|
||||
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
|
||||
clearAll: (serverId: string) => Promise<void>;
|
||||
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
|
||||
@@ -74,6 +80,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
tracks: {},
|
||||
albums: {},
|
||||
jobs: [],
|
||||
bulkProgress: {},
|
||||
|
||||
isDownloaded: (trackId, serverId) =>
|
||||
!!get().tracks[`${serverId}:${trackId}`],
|
||||
@@ -110,7 +117,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
return { done, total: albumJobs.length };
|
||||
},
|
||||
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
|
||||
const CONCURRENCY = 2;
|
||||
const trackIds = songs.map(s => s.id);
|
||||
|
||||
@@ -118,7 +125,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
set(state => ({
|
||||
albums: {
|
||||
...state.albums,
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
|
||||
},
|
||||
jobs: [
|
||||
...state.jobs.filter(j => j.albumId !== albumId),
|
||||
@@ -211,6 +218,42 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
|
||||
// Deduplicate songs (a track can appear multiple times in a playlist).
|
||||
const seen = new Set<string>();
|
||||
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
|
||||
// Store the entire playlist as one virtual album entry so the Offline Library
|
||||
// shows a single card for the playlist rather than one card per album.
|
||||
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
|
||||
},
|
||||
|
||||
downloadArtist: async (artistId, artistName, serverId) => {
|
||||
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
|
||||
try {
|
||||
const res = await getArtist(artistId);
|
||||
albums = res.albums;
|
||||
} catch { return; }
|
||||
set(state => ({
|
||||
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
|
||||
}));
|
||||
for (let i = 0; i < albums.length; i++) {
|
||||
const album = albums[i];
|
||||
try {
|
||||
const { songs } = await getAlbum(album.id);
|
||||
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
|
||||
} catch { /* skip failed album */ }
|
||||
set(state => ({
|
||||
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
|
||||
}));
|
||||
}
|
||||
setTimeout(() => {
|
||||
set(state => {
|
||||
const { [artistId]: _removed, ...rest } = state.bulkProgress;
|
||||
return { bulkProgress: rest };
|
||||
});
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
deleteAlbum: async (albumId, serverId) => {
|
||||
const album = get().albums[`${serverId}:${albumId}`];
|
||||
if (!album) return;
|
||||
|
||||
+229
-21
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
@@ -26,6 +27,10 @@ export interface Track {
|
||||
replayGainPeak?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
autoAdded?: boolean;
|
||||
radioAdded?: boolean;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -48,6 +53,8 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
starred: song.starred,
|
||||
genre: song.genre,
|
||||
samplingRate: song.samplingRate,
|
||||
bitDepth: song.bitDepth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,12 +73,12 @@ interface PlayerState {
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
togglePlay: () => void;
|
||||
next: () => void;
|
||||
next: (manual?: boolean) => void;
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
@@ -79,6 +86,8 @@ interface PlayerState {
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
enqueueAt: (tracks: Track[], insertIndex: number) => void;
|
||||
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
||||
setRadioArtistId: (artistId: string) => void;
|
||||
clearQueue: () => void;
|
||||
|
||||
isQueueVisible: boolean;
|
||||
@@ -112,6 +121,10 @@ interface PlayerState {
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
openSongInfo: (songId: string) => void;
|
||||
closeSongInfo: () => void;
|
||||
}
|
||||
|
||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||
@@ -125,6 +138,14 @@ let isAudioPaused = false;
|
||||
// playGeneration has moved on, preventing stale errors from skipping wrong tracks.
|
||||
let playGeneration = 0;
|
||||
|
||||
// Guard against concurrent infinite-queue fetches.
|
||||
let infiniteQueueFetching = false;
|
||||
// Guard against concurrent radio top-up fetches.
|
||||
let radioFetching = false;
|
||||
// Artist ID used to start the current radio session — persists across track
|
||||
// advances so proactive loading works even when songs lack artistId.
|
||||
let currentRadioArtistId: string | null = null;
|
||||
|
||||
// Debounce timer for seek slider drags.
|
||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||
@@ -246,9 +267,9 @@ function handleAudioEnded() {
|
||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue);
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||
} else {
|
||||
usePlayerStore.getState().next();
|
||||
usePlayerStore.getState().next(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
@@ -294,8 +315,8 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome + Last.fm
|
||||
reportNowPlaying(nextTrack.id);
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
|
||||
if (lastfmSessionKey) {
|
||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||
@@ -312,12 +333,16 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
function handleAudioError(message: string) {
|
||||
console.error('[psysonic] Audio error from backend:', message);
|
||||
isAudioPaused = false;
|
||||
|
||||
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
||||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||||
|
||||
const gen = playGeneration;
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
usePlayerStore.getState().next();
|
||||
}, 500);
|
||||
usePlayerStore.getState().next(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,9 +427,50 @@ export function initAudioListeners(): () => void {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Discord Rich Presence sync ────────────────────────────────────────────
|
||||
// Updates on track change or play/pause toggle. No per-tick updates needed —
|
||||
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
|
||||
let discordPrevTrackId: string | null = null;
|
||||
let discordPrevIsPlaying: boolean | null = null;
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
|
||||
const { discordRichPresence } = useAuthStore.getState();
|
||||
|
||||
if (!discordRichPresence || !currentTrack) {
|
||||
if (discordPrevTrackId !== null) {
|
||||
discordPrevTrackId = null;
|
||||
discordPrevIsPlaying = null;
|
||||
invoke('discord_clear_presence').catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trackChanged = currentTrack.id !== discordPrevTrackId;
|
||||
const playingChanged = isPlaying !== discordPrevIsPlaying;
|
||||
if (!trackChanged && !playingChanged) return;
|
||||
|
||||
discordPrevTrackId = currentTrack.id;
|
||||
discordPrevIsPlaying = isPlaying;
|
||||
|
||||
invoke('discord_update_presence', {
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist ?? 'Unknown Artist',
|
||||
album: currentTrack.album ?? null,
|
||||
// Pass elapsed when playing so Discord shows a live running timer.
|
||||
// Pass null when paused — Discord shows the song/artist without a timer.
|
||||
elapsedSecs: isPlaying ? currentTime : null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
|
||||
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubMpris();
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -439,6 +505,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
contextMenu: { ...state.contextMenu, isOpen: false },
|
||||
})),
|
||||
|
||||
songInfoModal: { isOpen: false, songId: null },
|
||||
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
|
||||
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
|
||||
|
||||
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
|
||||
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
|
||||
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
@@ -506,7 +576,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── playTrack ────────────────────────────────────────────────────────────
|
||||
playTrack: (track, queue) => {
|
||||
playTrack: (track, queue, manual = true) => {
|
||||
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
||||
// this playTrack call is likely a stale IPC echo — suppress it.
|
||||
if (Date.now() - lastGaplessSwitchTime < 500) {
|
||||
@@ -547,19 +617,20 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
manual,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next();
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
reportNowPlaying(track.id);
|
||||
const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
if (npEnabled) reportNowPlaying(track.id);
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||
@@ -612,6 +683,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
manual: false,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
@@ -639,6 +711,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
manual: false,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
@@ -658,17 +731,117 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: () => {
|
||||
const { queue, queueIndex, repeatMode } = get();
|
||||
next: (manual = true) => {
|
||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
get().playTrack(queue[nextIdx], queue, manual);
|
||||
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
||||
// so the queue never runs dry without a visible loading pause.
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching) {
|
||||
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
|
||||
if (remainingAuto <= 2) {
|
||||
infiniteQueueFetching = true;
|
||||
getRandomSongs(5, currentTrack?.genre).then(songs => {
|
||||
if (songs.length > 0) {
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
set(state => ({ queue: [...state.queue, ...newTracks] }));
|
||||
}
|
||||
}).catch(() => {}).finally(() => { infiniteQueueFetching = false; });
|
||||
}
|
||||
}
|
||||
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
|
||||
// of infinite queue setting.
|
||||
const nextTrack = queue[nextIdx];
|
||||
if (nextTrack.radioAdded && !radioFetching) {
|
||||
const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length;
|
||||
if (remainingRadio <= 2) {
|
||||
const artistId = nextTrack.artistId ?? currentRadioArtistId ?? null;
|
||||
const artistName = nextTrack.artist;
|
||||
if (artistId) {
|
||||
radioFetching = true;
|
||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)])
|
||||
.then(([similar, top]) => {
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
const fresh: Track[] = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => !existingIds.has(t.id))
|
||||
.slice(0, 10)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (fresh.length > 0) {
|
||||
set(state => ({ queue: [...state.queue, ...fresh] }));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { radioFetching = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
get().playTrack(queue[0], queue, manual);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
// Queue exhausted. Check radio first (independent of infinite queue setting),
|
||||
// then infinite queue, then stop.
|
||||
if (currentTrack?.radioAdded && !radioFetching) {
|
||||
const artistId = currentTrack.artistId ?? currentRadioArtistId ?? null;
|
||||
if (artistId) {
|
||||
radioFetching = true;
|
||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)])
|
||||
.then(([similar, top]) => {
|
||||
radioFetching = false;
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
const fresh: Track[] = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => !existingIds.has(t.id))
|
||||
.slice(0, 10)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (fresh.length > 0) {
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...fresh];
|
||||
get().playTrack(fresh[0], newQueue, false);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
radioFetching = false;
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||
if (infiniteQueueFetching) return;
|
||||
infiniteQueueFetching = true;
|
||||
getRandomSongs(5, currentTrack?.genre).then(songs => {
|
||||
infiniteQueueFetching = false;
|
||||
if (songs.length === 0) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...newTracks];
|
||||
get().playTrack(newTracks[0], newQueue, false);
|
||||
}).catch(() => {
|
||||
infiniteQueueFetching = false;
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
});
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -715,7 +888,42 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// ── queue management ─────────────────────────────────────────────────────
|
||||
enqueue: (tracks) => {
|
||||
set(state => {
|
||||
const newQueue = [...state.queue, ...tracks];
|
||||
// Insert before the first upcoming auto-added track so the
|
||||
// "Added automatically" separator always stays at the boundary.
|
||||
const firstAutoIdx = state.queue.findIndex(
|
||||
(t, i) => t.autoAdded && i > state.queueIndex
|
||||
);
|
||||
const newQueue = firstAutoIdx === -1
|
||||
? [...state.queue, ...tracks]
|
||||
: [
|
||||
...state.queue.slice(0, firstAutoIdx),
|
||||
...tracks,
|
||||
...state.queue.slice(firstAutoIdx),
|
||||
];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
|
||||
setRadioArtistId: (artistId) => { currentRadioArtistId = artistId; },
|
||||
|
||||
enqueueRadio: (tracks, artistId) => {
|
||||
if (artistId) currentRadioArtistId = artistId;
|
||||
set(state => {
|
||||
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
|
||||
// again replaces the pending radio batch instead of stacking on top.
|
||||
const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1);
|
||||
const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded);
|
||||
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
|
||||
const firstAutoIdx = upcoming.findIndex(t => t.autoAdded);
|
||||
const merged = firstAutoIdx === -1
|
||||
? [...upcoming, ...tracks]
|
||||
: [
|
||||
...upcoming.slice(0, firstAutoIdx),
|
||||
...tracks,
|
||||
...upcoming.slice(firstAutoIdx),
|
||||
];
|
||||
const newQueue = [...beforeAndCurrent, ...merged];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface SidebarItemConfig {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// All configurable nav items in their default order.
|
||||
// Fixed items (nowPlaying, settings, offline) are not listed here.
|
||||
export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'mainstage', visible: true },
|
||||
{ id: 'newReleases', visible: true },
|
||||
{ id: 'allAlbums', visible: true },
|
||||
{ id: 'randomAlbums', visible: true },
|
||||
{ id: 'artists', visible: true },
|
||||
{ id: 'genres', visible: true },
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'favorites', visible: true },
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
|
||||
interface SidebarStore {
|
||||
items: SidebarItemConfig[];
|
||||
setItems: (items: SidebarItemConfig[]) => void;
|
||||
toggleItem: (id: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: DEFAULT_SIDEBAR_ITEMS,
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
|
||||
toggleItem: (id) => set((s) => ({
|
||||
items: s.items.map(item => item.id === id ? { ...item, visible: !item.visible } : item),
|
||||
})),
|
||||
|
||||
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
|
||||
}),
|
||||
{ name: 'psysonic_sidebar' }
|
||||
)
|
||||
);
|
||||
+353
-9
@@ -723,6 +723,63 @@
|
||||
color: var(--color-error, #e05050);
|
||||
}
|
||||
|
||||
/* Filter tabs */
|
||||
.offline-filter-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.offline-filter-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.offline-filter-tab:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.offline-filter-tab.active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.offline-filter-tab-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.offline-filter-tab.active .offline-filter-tab-count {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Artist discography groups */
|
||||
.offline-artist-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.offline-artist-group-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.album-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1076,6 +1133,87 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─ Song Info Modal ─ */
|
||||
.song-info-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9990;
|
||||
}
|
||||
.song-info-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 9991;
|
||||
width: 460px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 80vh;
|
||||
background: var(--bg-card, var(--bg-secondary));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.song-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.song-info-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.song-info-close {
|
||||
padding: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.song-info-body {
|
||||
overflow-y: auto;
|
||||
padding: 12px 4px 16px;
|
||||
}
|
||||
.song-info-loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 24px;
|
||||
}
|
||||
.song-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.song-info-label {
|
||||
color: var(--text-muted);
|
||||
padding: 4px 16px 4px 16px;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
width: 140px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.song-info-value {
|
||||
color: var(--text-primary);
|
||||
padding: 4px 16px 4px 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.song-info-divider {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.song-info-path {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist {
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
@@ -1087,7 +1225,7 @@
|
||||
|
||||
.tracklist-header {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1101,12 +1239,12 @@
|
||||
}
|
||||
|
||||
.tracklist-header.tracklist-va {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1117,12 +1255,12 @@
|
||||
}
|
||||
|
||||
.track-row.track-row-va {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.tracklist-total {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px 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);
|
||||
@@ -1130,7 +1268,7 @@
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-va {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.tracklist-total-label {
|
||||
@@ -1164,7 +1302,7 @@
|
||||
.tracklist-header.tracklist-playlist,
|
||||
.track-row.tracklist-playlist,
|
||||
.tracklist-total.tracklist-playlist {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-label {
|
||||
@@ -1247,10 +1385,26 @@
|
||||
}
|
||||
|
||||
.track-num {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.track-num .bulk-check {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.track-num .bulk-check.bulk-check-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.track-row:hover .track-num .bulk-check {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Equalizer bars — shown for the currently playing track */
|
||||
.eq-bars {
|
||||
@@ -1399,6 +1553,70 @@
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
/* ── Random Mix — clickable artist name ── */
|
||||
.rm-artist-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: transparent;
|
||||
transition: color 0.15s, text-decoration-color 0.15s;
|
||||
}
|
||||
.rm-artist-btn:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
.rm-artist-btn.is-blocked {
|
||||
color: var(--danger);
|
||||
text-decoration-color: var(--danger);
|
||||
cursor: default;
|
||||
}
|
||||
.rm-artist-btn.just-added {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Random Mix — genre pill chip ── */
|
||||
.rm-genre-chip {
|
||||
display: inline-block;
|
||||
background: var(--bg-hover);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rm-genre-chip:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rm-genre-chip.is-blocked {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
cursor: default;
|
||||
}
|
||||
.rm-genre-chip.just-added {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─ Modal ─ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -1856,7 +2074,7 @@
|
||||
.lyrics-line {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
@@ -1865,10 +2083,18 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lyrics-line.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lyrics-line:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lyrics-plain {
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
@@ -1947,6 +2173,66 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* ─ Sidebar Customizer ─ */
|
||||
.sidebar-customizer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 8px var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast), opacity var(--transition-fast);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar-customizer-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-customizer-row.drag-over {
|
||||
background: var(--accent-dim);
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sidebar-customizer-row.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-customizer-grip {
|
||||
cursor: grab;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-customizer-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sidebar-customizer-block-label {
|
||||
padding: 6px var(--space-4) 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-customizer-fixed-hint {
|
||||
padding: 8px var(--space-4) 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4366,9 +4652,67 @@
|
||||
}
|
||||
|
||||
.playlist-card-delete--confirm {
|
||||
background: rgba(220, 60, 60, 0.9) !important;
|
||||
background: #dc3c3c !important;
|
||||
color: #fff !important;
|
||||
opacity: 1 !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
top: 3px !important;
|
||||
right: 3px !important;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45);
|
||||
animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes delete-confirm-pulse {
|
||||
from { background: #dc3c3c; }
|
||||
to { background: #ff2222; }
|
||||
}
|
||||
|
||||
/* ─ Bulk Select ─ */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--accent-dim, color-mix(in srgb, var(--accent) 15%, transparent));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.bulk-action-count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-right: auto;
|
||||
}
|
||||
.bulk-pl-picker-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bulk-check {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid var(--text-muted);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.1s, background 0.1s;
|
||||
}
|
||||
.bulk-check.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 6l3 3 5-5' stroke='%23fff' stroke-width='1.8' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-size: 10px 10px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.track-row.bulk-selected {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||||
}
|
||||
.track-row.bulk-selected:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Drop indicator line */
|
||||
|
||||
+130
-2
@@ -243,6 +243,129 @@
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── AppUpdater floating toast ── */
|
||||
.app-updater-toast {
|
||||
position: fixed;
|
||||
bottom: calc(var(--player-height, 80px) + 12px);
|
||||
left: 12px;
|
||||
width: 210px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 9000;
|
||||
animation: update-toast-in 0.35s ease both;
|
||||
}
|
||||
.app-updater-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-updater-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
.app-updater-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-dismiss:hover { opacity: 1; }
|
||||
.app-updater-version {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 19px;
|
||||
}
|
||||
.app-updater-actions {
|
||||
padding-left: 19px;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.app-updater-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.app-updater-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.85;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-btn-primary:hover { opacity: 1; }
|
||||
.app-updater-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.75;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-btn-secondary:hover { opacity: 1; color: var(--text-primary); }
|
||||
.app-updater-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-left: 19px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.app-updater-progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--bg-glass);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-updater-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
.app-updater-pct {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
min-width: 26px;
|
||||
text-align: right;
|
||||
}
|
||||
.app-updater-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 19px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.update-toast {
|
||||
margin: 0 var(--space-1) var(--space-2);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
@@ -592,6 +715,11 @@
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Star + Last.fm heart — visually separated from track title */
|
||||
.player-star-btn {
|
||||
margin: var(--space-1);
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
@@ -617,8 +745,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: 32px;
|
||||
margin-right: 32px;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.player-waveform-wrap {
|
||||
|
||||
+5852
-3838
File diff suppressed because it is too large
Load Diff
+40
-10
@@ -9,16 +9,36 @@ const MAX_CONCURRENT_FETCHES = 5;
|
||||
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
|
||||
// Concurrency limiter for network fetches
|
||||
// Concurrency limiter for network fetches.
|
||||
// Each queue entry is a resolver that signals "slot acquired".
|
||||
let activeFetches = 0;
|
||||
const fetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquireFetchSlot(): Promise<void> {
|
||||
/**
|
||||
* Acquires a fetch slot. Returns true if a slot was granted, false if the
|
||||
* provided AbortSignal fired while the call was waiting in the queue (in that
|
||||
* case no slot is held and the caller must NOT call releaseFetchSlot).
|
||||
*/
|
||||
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
||||
activeFetches++;
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise(resolve => fetchQueue.push(resolve));
|
||||
return new Promise<boolean>(resolve => {
|
||||
const onGrant = () => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
};
|
||||
const onAbort = () => {
|
||||
// Remove from queue without consuming a slot — no releaseFetchSlot needed.
|
||||
const idx = fetchQueue.indexOf(onGrant);
|
||||
if (idx !== -1) fetchQueue.splice(idx, 1);
|
||||
resolve(false);
|
||||
};
|
||||
fetchQueue.push(onGrant);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function releaseFetchSlot(): void {
|
||||
@@ -174,9 +194,11 @@ export async function clearImageCache(): Promise<void> {
|
||||
* Returns a cached object URL for an image.
|
||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||
* @param cacheKey A stable key that identifies the image across sessions.
|
||||
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches
|
||||
* so navigating away does not leave zombie fetches draining I/O.
|
||||
*/
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
|
||||
if (!fetchUrl) return '';
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<string> {
|
||||
if (!fetchUrl || signal?.aborted) return '';
|
||||
|
||||
// 1. In-memory hit (same session)
|
||||
const existing = objectUrlCache.get(cacheKey);
|
||||
@@ -184,6 +206,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
|
||||
// 2. IndexedDB hit (persisted from previous session)
|
||||
const blob = await getBlob(cacheKey);
|
||||
if (signal?.aborted) return '';
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
@@ -191,19 +214,26 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL
|
||||
await acquireFetchSlot();
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL.
|
||||
// acquireFetchSlot returns false (without holding a slot) when aborted in queue.
|
||||
const acquired = await acquireFetchSlot(signal);
|
||||
if (!acquired || signal?.aborted) {
|
||||
if (acquired) releaseFetchSlot();
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
const newBlob = await resp.blob();
|
||||
if (signal?.aborted) return '';
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictMemoryIfNeeded();
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl;
|
||||
} catch (e) {
|
||||
// AbortError → return '' (component is gone). Other errors → return raw URL.
|
||||
return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl;
|
||||
} finally {
|
||||
releaseFetchSlot();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Lightweight DOM-based toast notification.
|
||||
* Uses the app's CSS custom properties so it respects the active theme.
|
||||
* Multiple toasts stack vertically above each other.
|
||||
*/
|
||||
|
||||
const TOAST_GAP = 8;
|
||||
const TOAST_BOTTOM_ANCHOR = 100;
|
||||
|
||||
function getActiveToasts(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.psysonic-toast'));
|
||||
}
|
||||
|
||||
function reflow(): void {
|
||||
const toasts = getActiveToasts();
|
||||
let bottom = TOAST_BOTTOM_ANCHOR;
|
||||
for (let i = toasts.length - 1; i >= 0; i--) {
|
||||
toasts[i].style.bottom = `${bottom}px`;
|
||||
bottom += toasts[i].offsetHeight + TOAST_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToastVariant = 'error' | 'info';
|
||||
|
||||
export function showToast(text: string, durationMs = 4000, variant: ToastVariant = 'info'): void {
|
||||
const isError = variant === 'error';
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'psysonic-toast';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.textContent = isError ? '✕' : 'ℹ';
|
||||
icon.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${isError ? 'var(--danger)' : 'var(--accent)'};
|
||||
color: var(--bg-app);
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = text;
|
||||
msg.style.cssText = `flex: 1; min-width: 0;`;
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: ${TOAST_BOTTOM_ANCHOR}px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid ${isError ? 'var(--danger)' : 'var(--border)'};
|
||||
border-left: 3px solid ${isError ? 'var(--danger)' : 'var(--accent)'};
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
z-index: 999999;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45)${isError ? ', 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)' : ''};
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
transition: bottom 150ms ease;
|
||||
max-width: 480px;
|
||||
width: max-content;
|
||||
`;
|
||||
|
||||
toast.appendChild(icon);
|
||||
toast.appendChild(msg);
|
||||
document.body.appendChild(toast);
|
||||
reflow();
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
reflow();
|
||||
}, durationMs);
|
||||
}
|
||||
Reference in New Issue
Block a user