Compare commits

...

20 Commits

Author SHA1 Message Date
Psychotoxical f9bc67cb77 fix(aur): append -fuse-ld=bfd to RUSTFLAGS — substitution was no-op on CachyOS 2026-04-02 11:54:50 +02:00
Psychotoxical 74c75d83ca fix(aur): override RUSTFLAGS to swap lld→bfd for ring linker error
makepkg.conf on Arch injects -fuse-ld=lld into RUSTFLAGS which overrides
.cargo/config.toml target rustflags. PKGBUILD now explicitly patches
RUSTFLAGS at env level so bfd is used regardless of system config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:44:00 +02:00
Psychotoxical 005abae97d fix: explicit Windows NSIS updater bundle signing step
tauri-action uploads the .exe but doesn't create .nsis.zip/.sig because
the bundler doesn't pick up TAURI_SIGNING_PRIVATE_KEY automatically.
Added a PowerShell step that zips the exe, signs it via tauri signer,
and uploads .nsis.zip + .nsis.zip.sig — mirroring the macOS approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:23:05 +02:00
Psychotoxical a9c20dfbdf chore: bump to v1.27.3 — CI fixes, ring linker fix, draft releases
- fix: CI Windows NSIS upload — let tauri-action handle artifact upload directly
- fix: Linux/AUR ring linker error — cc + -fuse-ld=bfd via .cargo/config.toml
- fix: releases now created as draft for review before publishing
- chore: consolidate 1.27.0–1.27.2 changelog into single entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:06:18 +02:00
Psychotoxical 0b5db172bd chore: bump to v1.27.2 — bugfix release
- fix: Radio from context menu passed artist name as ID to getSimilarSongs2 (closes #29)
- fix: CI Windows NSIS upload — tauri-action no longer searches missing bundle/msi/
- fix: Linux/AUR ring linker error — use cc instead of rust-lld via .cargo/config.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:38:26 +02:00
Psychotoxical bf99a64baf fix: guard against empty NSIS bundle in Windows signing step
tauri-action already signs and uploads the .nsis.zip.sig when
TAURI_SIGNING_PRIVATE_KEY is set, so the manual find returns empty
and crashed with "a value is required for <FILE>". Exit 0 cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 03:23:17 +02:00
Psychotoxical 523596e414 chore: bump AUR pkgver to 1.27.1 2026-04-02 03:08:52 +02:00
Psychotoxical 2fe35e3f9b fix: v1.27.1 — CI signing pipeline, sig naming, npm+cargo caching 2026-04-02 03:07:19 +02:00
Psychotoxical e9fa541933 chore: add npm + cargo caching to all CI jobs (netherguy4 #28) 2026-04-02 02:54:58 +02:00
Psychotoxical 746aa69405 fix: explicit bundle signing step after tauri-action 2026-04-02 02:53:08 +02:00
Psychotoxical 205b2c1914 fix: signing env at job level, fix macOS bundle filenames in manifest 2026-04-02 02:38:53 +02:00
Psychotoxical 0086b3e310 chore: bump AUR pkgver to 1.27.0 2026-04-02 02:15:26 +02:00
Psychotoxical 55e7cb835b feat: v1.27.0 — In-App Auto-Update, Configurable Home, Icon Consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 02:14:57 +02:00
Psychotoxical d8da511a8f fix: v1.26.1 — hero/album/playlist background flickering
Background of Hero carousel, Album Detail and Playlist Detail was
flickering for up to 20 seconds on first visit. useCachedUrl with
fallbackToFetch=true returned the raw server URL immediately, causing
a double render — once with the HTTP URL and again when the blob was
ready. Fixed by passing fallbackToFetch=false in all three locations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:20:15 +02:00
Psychotoxical 434ee0ecf1 feat: v1.26.0 — Bulk Select, Song Info, Favorite Button, Recently Played
### Added
- Favorite/Star button in player bar (requested by @halfkey)
- Bulk multi-select for album tracklist and playlist detail (add to playlist, remove from playlist)
- Song Info modal via right-click context menu (metadata: format, bitrate, sample rate, bit depth, channels, file size, path, replay gain)
- Recently Played section on Home page
- "Show activity in Now Playing" opt-in toggle in Settings → Behavior

### Fixed
- Queue cover art not updating on track change (useCachedUrl/CachedImage reset on cacheKey change)
- FullscreenPlayer background flickering (FsBg preloads image before layer transition)
- Playlist card delete confirmation visual (size expansion + pulse animation)
- Gruvbox Light Soft: back button and badge invisible against light background

### Changed
- buildStreamUrl: removed unused suffix parameter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:57:57 +02:00
Psychotoxical 7d1c66071e Update CHANGELOG.md 2026-04-01 17:28:50 +02:00
Psychotoxical 1adfda1daa fix: remove invalid single-instance capability + dead static tray icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:51:50 +02:00
Psychotoxical e65c476a76 fix: v1.25.1 — Opus playback, single-instance enforcement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:45:37 +02:00
Psychotoxical 560349819f feat: v1.25.0 — Tray Icon, Minimize to Tray, Sidebar Customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:46:41 +02:00
Psychotoxical ada5327493 chore: bump AUR pkgver to 1.24.0 2026-03-31 23:14:45 +02:00
45 changed files with 2754 additions and 388 deletions
+12
View File
@@ -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"]
+93 -1
View File
@@ -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
+75
View File
@@ -5,6 +5,81 @@ 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.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
+22 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.24.0",
"version": "1.27.4",
"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",
+9 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.23.0
pkgver=1.27.4
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -16,6 +16,7 @@ makedepends=(
'npm'
'rust'
'cargo'
'clang'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
@@ -26,6 +27,13 @@ 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"
npm install
npm run tauri:build -- --no-bundle
}
+70
View File
@@ -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(', ')}`);
+381 -14
View File
@@ -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"
@@ -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"
@@ -1059,6 +1109,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 +1241,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 +2428,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 +2570,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 +2859,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2798,6 +2875,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 +3036,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 +3099,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -3176,6 +3279,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,7 +3470,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.24.0"
version = "1.27.2"
dependencies = [
"biquad",
"md5",
@@ -3376,8 +3485,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 +3626,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 +3752,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 +3888,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 +3916,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"
@@ -4231,7 +4397,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -4279,8 +4445,8 @@ dependencies = [
"pollster",
"thiserror 1.0.69",
"windows 0.44.0",
"zbus",
"zvariant",
"zbus 3.15.2",
"zvariant 3.15.2",
]
[[package]]
@@ -4613,6 +4779,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"
@@ -4806,6 +4983,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 +5014,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 +5045,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"
@@ -5734,6 +5969,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 +6768,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 +6823,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 +6853,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",
"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 +6904,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 +6930,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 +7024,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 +7053,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 +7080,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 +7106,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",
]
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.24.0"
version = "1.27.4"
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"
@@ -36,4 +37,6 @@ 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"] }
+3 -1
View File
@@ -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
View File
@@ -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"]}}
+84
View File
@@ -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",
+84
View File
@@ -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",
+84 -1
View File
@@ -6,7 +6,11 @@ mod audio;
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).
@@ -292,14 +296,84 @@ pub fn run() {
tauri::Builder::default()
.manage(audio_engine)
.manage(ShortcutMap::default())
.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 +466,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,
+9 -7
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.24.0",
"version": "1.27.4",
"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": {
+19 -8
View File
@@ -31,6 +31,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 +44,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 +274,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 +342,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 +379,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);
+3
View File
@@ -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 {
@@ -335,6 +337,7 @@ export function buildStreamUrl(id: string): string {
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
+2 -2
View File
@@ -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';
@@ -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>
+168 -74
View File
@@ -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);
@@ -71,9 +72,45 @@ export default function AlbumTrackList({
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 +120,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 +181,99 @@ 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' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
onClick={e => {
if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) {
toggleSelect(song.id, globalIdx, e.shiftKey);
}
}}
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();
if (inSelectMode || hoveredSongId === song.id) {
toggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
? <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 ?? globalIdx + 1)}
</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>
))}
+140
View File
@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
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 relaunch();
} 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 && (
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
)}
{isLinuxFallback && (
<button className="app-updater-btn-primary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
)}
</div>
)}
</div>,
document.body
);
}
+7 -3
View File
@@ -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) => {
+15 -8
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -6,31 +6,38 @@ 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;
setResolved('');
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; };
}, [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('');
if (resolvedSrc !== prevSrc.current) {
prevSrc.current = resolvedSrc;
// 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' }}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
{...props}
/>
+25 -14
View File
@@ -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();
@@ -294,7 +297,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))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => {
@@ -302,7 +305,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 +318,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 +347,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 +381,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 +422,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 +435,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))}>
<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>
</>
);
})()}
+26 -14
View File
@@ -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>('');
+6 -7
View File
@@ -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" />;
+31 -2
View File
@@ -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) : '';
@@ -92,6 +110,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 +129,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>
+22 -4
View File
@@ -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 { 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);
@@ -359,19 +369,27 @@ export default function QueuePanel() {
{currentTrack && (
<div className="queue-current-track">
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
{(currentTrack.genre || 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>
)}
+41 -96
View File
@@ -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' : ''}`}
+145
View File
@@ -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
);
}
+3
View File
@@ -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;
+230
View File
@@ -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',
@@ -300,6 +303,16 @@ 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',
},
settings: {
title: 'Settings',
@@ -361,6 +374,10 @@ const enTranslation = {
cacheClearWarning: 'This will also remove all offline albums from the library.',
cacheClearConfirm: 'Clear Everything',
cacheClearCancel: 'Cancel',
minimizeToTray: 'Minimize to Tray',
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
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',
@@ -390,6 +407,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',
@@ -594,6 +616,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',
@@ -606,6 +651,7 @@ const enTranslation = {
notFound: 'Playlist not found.',
songs: '{{n}} songs',
playAll: 'Play All',
shuffle: 'Shuffle',
addToQueue: 'Add to Queue',
back: 'Back to Playlists',
deletePlaylist: 'Delete',
@@ -649,9 +695,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 +771,7 @@ const deTranslation = {
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
addToPlaylist: 'Zur Playlist hinzufügen',
songInfo: 'Song-Infos',
},
albumDetail: {
back: 'Zurück',
@@ -921,6 +970,16 @@ 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',
},
settings: {
title: 'Einstellungen',
@@ -982,6 +1041,10 @@ const deTranslation = {
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
cacheClearConfirm: 'Alles löschen',
cacheClearCancel: 'Abbrechen',
minimizeToTray: 'Im Tray minimieren',
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
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',
@@ -1011,6 +1074,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',
@@ -1215,6 +1283,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',
@@ -1227,6 +1318,7 @@ const deTranslation = {
notFound: 'Playlist nicht gefunden.',
songs: '{{n}} Songs',
playAll: 'Alle abspielen',
shuffle: 'Zufallswiedergabe',
addToQueue: 'Zur Warteschlange',
back: 'Zurück zu Playlists',
deletePlaylist: 'Löschen',
@@ -1270,9 +1362,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 +1438,7 @@ const frTranslation = {
goToArtist: 'Aller à l\'artiste',
download: 'Télécharger (ZIP)',
addToPlaylist: 'Ajouter à la playlist',
songInfo: 'Infos du morceau',
},
albumDetail: {
back: 'Retour',
@@ -1542,6 +1637,16 @@ 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',
},
settings: {
title: 'Paramètres',
@@ -1603,6 +1708,10 @@ const frTranslation = {
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
cacheClearConfirm: 'Tout supprimer',
cacheClearCancel: 'Annuler',
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.',
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',
@@ -1632,6 +1741,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',
@@ -1836,6 +1950,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',
@@ -1848,6 +1985,7 @@ const frTranslation = {
notFound: 'Playlist introuvable.',
songs: '{{n}} titres',
playAll: 'Tout lire',
shuffle: 'Aléatoire',
addToQueue: 'Ajouter à la file',
back: 'Retour aux playlists',
deletePlaylist: 'Supprimer',
@@ -1891,9 +2029,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 +2105,7 @@ const nlTranslation = {
goToArtist: 'Naar artiest',
download: 'Downloaden (ZIP)',
addToPlaylist: 'Toevoegen aan playlist',
songInfo: 'Nummerinfo',
},
albumDetail: {
back: 'Terug',
@@ -2163,6 +2304,16 @@ 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',
},
settings: {
title: 'Instellingen',
@@ -2224,6 +2375,10 @@ const nlTranslation = {
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
cacheClearConfirm: 'Alles wissen',
cacheClearCancel: 'Annuleren',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
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',
@@ -2253,6 +2408,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',
@@ -2457,6 +2617,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',
@@ -2469,6 +2652,7 @@ const nlTranslation = {
notFound: 'Playlist niet gevonden.',
songs: '{{n}} nummers',
playAll: 'Alles afspelen',
shuffle: 'Willekeurig',
addToQueue: 'Aan wachtrij toevoegen',
back: 'Terug naar playlists',
deletePlaylist: 'Verwijderen',
@@ -2512,9 +2696,11 @@ const zhTranslation = {
playlists: '播放列表',
},
home: {
hero: '精选',
starred: '个人收藏',
recent: '最近添加',
mostPlayed: '最常播放',
recentlyPlayed: '最近播放',
discover: '发现',
loadMore: '加载更多',
discoverMore: '发现更多',
@@ -2586,6 +2772,7 @@ const zhTranslation = {
goToArtist: '前往艺术家',
download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表',
songInfo: '歌曲信息',
},
albumDetail: {
back: '返回',
@@ -2784,6 +2971,16 @@ const zhTranslation = {
filterSearchGenres: '搜索流派…',
filterNoGenres: '未找到匹配流派',
filterClear: '清除',
bulkSelected: '已选 {{count}} 首',
bulkAddToPlaylist: '添加到播放列表',
bulkRemoveFromPlaylist: '从播放列表移除',
bulkClear: '取消选择',
updaterAvailable: '有可用更新',
updaterVersion: 'v{{version}} 已就绪',
updaterInstall: '安装并重启',
updaterDownloading: '下载中…',
updaterInstalling: '安装中…',
updaterDownload: '从 GitHub 下载',
},
settings: {
title: '设置',
@@ -2845,6 +3042,10 @@ const zhTranslation = {
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
cacheClearConfirm: '全部清除',
cacheClearCancel: '取消',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
nowPlayingEnabled: '在实时窗口中显示',
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
downloadsTitle: '下载文件夹',
downloadsDefault: '默认下载文件夹',
pickFolder: '选择',
@@ -2874,6 +3075,11 @@ const zhTranslation = {
tabPlayback: '播放',
tabLibrary: '音乐库',
tabAppearance: '外观',
homeCustomizerTitle: '首页',
sidebarTitle: '侧边栏',
sidebarReset: '重置为默认',
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
tabShortcuts: '快捷键',
tabServer: '服务器',
tabAbout: '关于',
@@ -3078,6 +3284,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: '新建播放列表',
@@ -3090,6 +3319,7 @@ const zhTranslation = {
notFound: '未找到播放列表。',
songs: '{{n}} 首歌曲',
playAll: '全部播放',
shuffle: '随机播放',
addToQueue: '添加到队列',
back: '返回播放列表',
deletePlaylist: '删除',
+16 -7
View File
@@ -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';
@@ -112,9 +112,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) => {
@@ -225,9 +231,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>;
+2 -2
View File
@@ -4,7 +4,7 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, Subson
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 } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -283,7 +283,7 @@ 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>
+45 -24
View File
@@ -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>
+167 -41
View File
@@ -1,9 +1,10 @@
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 } 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';
@@ -68,6 +69,45 @@ export default function PlaylistDetail() {
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 next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next);
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 +122,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 +180,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);
}, []);
@@ -318,21 +373,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">
@@ -353,6 +398,19 @@ export default function PlaylistDetail() {
}}>
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
const tracks = 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!);
@@ -417,9 +475,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>
@@ -442,7 +544,7 @@ 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' : ''}`}
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 => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
onMouseLeave={() => setHoveredSongId(null)}
onMouseDown={e => handleRowMouseDown(e, idx)}
@@ -450,26 +552,50 @@ export default function PlaylistDetail() {
const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks);
}}
onClick={e => {
if (selectedIds.size > 0 && !(e.target as HTMLElement).closest('button, input')) {
toggleSelect(song.id, idx, e.shiftKey);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
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, grip/play on hover otherwise */}
{(() => {
const inSelectMode = selectedIds.size > 0;
return (
<div
className="track-num"
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
if (inSelectMode || hoveredSongId === song.id) {
toggleSelect(song.id, idx, e.shiftKey);
} else {
const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks);
}
}}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
/>
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
? <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}
</span>
</div>
);
})()}
{/* Title */}
<div className="track-info">
+17 -7
View File
@@ -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>
+241 -30
View File
@@ -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';
@@ -558,6 +563,8 @@ export default function Settings() {
</div>
</div>
</section>
<HomeCustomizer />
</>
)}
@@ -603,43 +610,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 +970,28 @@ 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.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>
@@ -1132,6 +1152,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);
+8
View File
@@ -33,6 +33,8 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
minimizeToTray: boolean;
nowPlayingEnabled: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
@@ -64,6 +66,8 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
logout: () => void;
@@ -96,6 +100,8 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
minimizeToTray: false,
nowPlayingEnabled: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
isLoggedIn: false,
@@ -160,6 +166,8 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+38
View File
@@ -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' }
)
);
+16 -4
View File
@@ -26,6 +26,8 @@ export interface Track {
replayGainPeak?: number;
starred?: string;
genre?: string;
samplingRate?: number;
bitDepth?: number;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -48,6 +50,8 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
genre: song.genre,
samplingRate: song.samplingRate,
bitDepth: song.bitDepth,
};
}
@@ -112,6 +116,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 ─────────────────────────────────────────
@@ -294,8 +302,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 => {
@@ -439,6 +447,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 })),
@@ -558,8 +570,8 @@ export const usePlayerStore = create<PlayerState>()(
});
// 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 => {
+47
View File
@@ -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' }
)
);
+219 -8
View File
@@ -1076,6 +1076,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 +1168,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 +1182,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 +1198,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 +1211,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 +1245,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 +1328,22 @@
}
.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;
}
/* Equalizer bars — shown for the currently playing track */
.eq-bars {
@@ -1947,6 +2040,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 +4519,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 */
+106 -2
View File
@@ -243,6 +243,105 @@
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;
}
.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-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 +691,11 @@
height: 28px;
}
/* Star + Last.fm heart — visually separated from track title */
.player-star-btn {
margin-left: var(--space-3);
}
.player-btn-primary {
width: 46px;
height: 46px;
@@ -617,8 +721,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 {
+14
View File
@@ -1053,6 +1053,20 @@
--danger: #9d0006;
}
/* ── Gruvbox Light Soft overrides ── */
[data-theme='gruvbox-light-soft'] .album-detail-back {
color: #fbf1c7;
}
[data-theme='gruvbox-light-soft'] .album-detail-back:hover {
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
}
[data-theme='gruvbox-light-soft'] .album-detail-badge {
background: var(--accent);
color: #fbf1c7;
}
/* ─── Lambda 17 — Half-Life / City 17 ─── */
/* HEV orange + Combine Overwatch teal + City 17 concrete */
[data-theme='lambda-17'] {