mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-25 16:55:45 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95283d792b | |||
| 53d5888ebf | |||
| cad4338324 | |||
| f9bc67cb77 | |||
| 74c75d83ca | |||
| 005abae97d | |||
| a9c20dfbdf | |||
| 0b5db172bd | |||
| bf99a64baf | |||
| 523596e414 | |||
| 2fe35e3f9b | |||
| e9fa541933 | |||
| 746aa69405 | |||
| 205b2c1914 | |||
| 0086b3e310 | |||
| 55e7cb835b | |||
| d8da511a8f | |||
| 434ee0ecf1 | |||
| 7d1c66071e | |||
| 1adfda1daa | |||
| e65c476a76 |
@@ -0,0 +1,12 @@
|
||||
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
|
||||
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
|
||||
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
|
||||
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
|
||||
# bfd is always available via binutils (part of base-devel on Arch).
|
||||
#
|
||||
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
|
||||
# overrides target-specific rustflags here. The PKGBUILD's build() applies
|
||||
# the same fix by patching the RUSTFLAGS env var directly.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "cc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
|
||||
@@ -19,6 +19,12 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
@@ -65,7 +71,7 @@ jobs:
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body,
|
||||
draft: false,
|
||||
draft: true,
|
||||
prerelease: false
|
||||
});
|
||||
return data.id;
|
||||
@@ -74,6 +80,9 @@ jobs:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -91,10 +100,20 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
@@ -106,6 +125,38 @@ jobs:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
- name: sign and upload Windows NSIS updater bundle
|
||||
if: matrix.settings.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
$dir = "src-tauri/target/release/bundle/nsis"
|
||||
$exe = (Get-ChildItem $dir -Filter "*.exe")[0].FullName
|
||||
$zip = $exe -replace '\.exe$', '.nsis.zip'
|
||||
Compress-Archive -Path $exe -DestinationPath $zip -Force
|
||||
npx tauri signer sign --private-key "$env:TAURI_SIGNING_PRIVATE_KEY" --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" $zip
|
||||
gh release upload "app-v$env:VERSION" $zip "$zip.sig" --clobber
|
||||
|
||||
- name: sign and upload macOS bundle signature
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.args }}" == *"aarch64"* ]]; then
|
||||
BUNDLE="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_aarch64.app.tar.gz.sig"
|
||||
else
|
||||
BUNDLE="src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
SIG_NAME="Psysonic_x64.app.tar.gz.sig"
|
||||
fi
|
||||
npx tauri signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$BUNDLE"
|
||||
cp "${BUNDLE}.sig" "$SIG_NAME"
|
||||
gh release upload "app-v${VERSION}" "$SIG_NAME" --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
@@ -126,9 +177,21 @@ jobs:
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -146,3 +209,32 @@ jobs:
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
@@ -5,6 +5,82 @@ 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.28.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Infinite Queue** *(requested by [@netherguy4](https://github.com/netherguy4))*: When the queue runs out with Repeat off, Psysonic automatically appends 25 random tracks (optionally filtered by the last-played track's genre) so playback never stops. Toggle in Settings → Audio → "Infinite Queue". Auto-added tracks appear below a divider in the Queue panel.
|
||||
- **Start Radio plays immediately** *(requested by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" from the song/queue context menu now starts the seed track instantly while similar and top tracks load in the background — no waiting for the fetch to complete before music plays.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Single-click to play everywhere** *(reported by [@netherguy4](https://github.com/netherguy4))*: Song rows in Album Detail, Playlist Detail, Artist Detail (Top Tracks), Favorites, and Random Mix previously required a double-click. All rows now play on a single click. The track-number cell and the full row are both click targets; buttons and links inside the row still work independently.
|
||||
- **Artist page Play All / Shuffle used Top Tracks only** *(reported by [@smirnoffjr](https://github.com/smirnoffjr))*: "Play All" and "Shuffle" on the Artist detail page only sent the loaded top songs to the queue, not the full discography. Now fetches all albums in parallel and plays songs in chronological album order with correct track-number ordering within each album. Buttons show a spinner while albums are loading.
|
||||
- **Last.fm icon clipped in player bar**: The Last.fm logo button in the player bar was cut off on the right side. Fixed by correcting the SVG `viewBox` from `0 0 24 24` to `0 0 26 22` to match the actual path extents.
|
||||
- **Playlist empty state UX** *(reported by [@netherguy4](https://github.com/netherguy4))*: Empty playlists (on creation, or after deleting all tracks) now show an "Add your first song" CTA button that opens the search panel directly, rather than a plain text message with no action.
|
||||
- **Playlist search rows required "+" button click** *(reported by [@netherguy4](https://github.com/netherguy4))*: Search result rows in the song search panel now add the song on a full-row click — the separate "+" button was redundant and easy to miss.
|
||||
- **Large playlist performance**: Playlists with hundreds of songs would freeze during mouse movement. Root cause: `hoveredSongId` state triggered a full React re-render of every row on every `mouseenter`/`mouseleave` event. Fixed by removing the JS hover state and replacing it with a CSS `.track-row:hover .bulk-check` rule. Also memoized `songs.map(songToTrack)` and the `existingIds` set to avoid recomputation per render. Same fix applied to `AlbumTrackList`.
|
||||
|
||||
---
|
||||
|
||||
## [1.27.4] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **In-App Auto-Update** *(requested by [@netherguy4](https://github.com/netherguy4))*: Psysonic now checks for new releases automatically on startup (3 s delay). On macOS and Windows a native install-and-relaunch flow is available directly in the app — no browser needed. On Linux, a download link to the GitHub release page is shown instead (AppImage is not built due to WebKitGTK incompatibility with Arch/Fedora). The updater uses Tauri's signed updater plugin with minisign signatures verified against a bundled public key.
|
||||
- **Configurable Home Page**: Users can now choose which sections appear on the home page. A new "Home Page" block in Settings → Library lets you toggle each section individually (Featured, Recently Added, Discover, Discover Artists, Recently Played, Personal Favorites, Most Played) with a reset-to-default button. Hidden sections are skipped entirely.
|
||||
- **Consistent icon language** *(requested by [@netherguy4](https://github.com/netherguy4))*: Favorites (local star/heart) now use a filled Heart icon everywhere — Player Bar, Album Detail, Artist Detail, Tracklist, Context Menu. Last.fm love always uses the Last.fm logo. Previously the two were mixed up in several places.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Radio broken from context menu** *(reported by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" in the track and queue-item context menus had no effect. The handler was passing the artist name as the artist ID to `getSimilarSongs2`, which returned an empty result — so no tracks were queued and no error was shown. Now correctly passes `song.artistId`.
|
||||
- **Album Detail hero background not loading**: The blurred album art background in Album Detail only appeared after a track change, never on first visit. Root cause: `buildCoverArtUrl` was called without `useMemo`, generating a new salt on every re-render — causing `useCachedUrl` to cancel and restart its fetch endlessly. Fixed by memoising both the URL and cache key on `album.coverArt`. Same fix applied to Hero and Playlist Detail backgrounds.
|
||||
- **CI: auto-update signing pipeline**: Signing keys were not being passed correctly during the build, and macOS `.sig` files were uploaded with a generic name the manifest generator couldn't match. Fixed the post-build signing step to upload arch-specific names (`Psysonic_aarch64.app.tar.gz.sig`, `Psysonic_x64.app.tar.gz.sig`). First release where the in-app updater is fully functional on macOS and Windows.
|
||||
- **CI: Windows NSIS upload**: The release workflow was not correctly uploading Windows artifacts. Resolved by letting `tauri-action` handle NSIS bundle detection and upload directly — it only searches for what was actually built, so there is no MSI conflict with `--bundles nsis` builds.
|
||||
- **CI: npm + Cargo caching** *(contributed by [@netherguy4](https://github.com/netherguy4))*: Added `actions/cache` for npm and `Swatinem/rust-cache` for Cargo across all build jobs. Warm-cache builds will be significantly faster on subsequent releases.
|
||||
- **Linux/AUR build: ring linker error**: Builds on Arch/CachyOS failed with `rust-lld: undefined symbol: ring_core_*` after the Tauri updater was added. Arch's `rust` package bakes `-fuse-ld=lld` into the default rustflags; ring's C/asm objects are incompatible with lld. Fixed via `.cargo/config.toml` — forces `cc` as linker driver with `-fuse-ld=bfd` to override the hardcoded lld flag. Added `clang` to the AUR `makedepends` (required by ring's bindgen step).
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background flickering in Hero, Album Detail and Playlist Detail**: Blurred hero backgrounds were flickering for up to 20 seconds on first visit. Root cause: `useCachedUrl` with the default `fallbackToFetch = true` immediately returned the raw server URL, causing the background to render twice — once with the HTTP URL (triggering a server fetch) and again when the IndexedDB blob was ready. Fixed by passing `fallbackToFetch = false` in all three locations so the background only renders once the blob is cached.
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Favorite button in Player Bar** *(requested by [@halfkey](https://github.com/halfkey))*: A star icon button now sits next to the Last.fm heart in the player bar. Clicking it toggles the favorite/unfavorite state for the currently playing track with an optimistic UI update — no page reload needed. Uses the same `starredOverrides` mechanism as the album tracklist for instant feedback.
|
||||
- **Bulk Select for song lists**: Multi-select support in Album tracklist and Playlist detail. A checkbox fades in to the left of the track number on hover. Selecting one or more tracks activates the bulk action bar at the top with two actions: **Add to Playlist** (opens the playlist picker submenu) and **Remove from Playlist** (Playlist detail only). Shift-click selects a range; the header checkbox selects / deselects all. CSS uses `color-mix` for the selection highlight, compatible with all 60 themes.
|
||||
- **Song Info modal**: Right-clicking any song and choosing "Song Info" opens a metadata panel fetched live via `getSong`. Displays: title, artist, album, album artist, year, genre, duration, track number; format, bitrate, sample rate, bit depth, channels (Mono / Stereo), file size; file path; and Replay Gain values (track / album gain + peak) when present. Closes with Escape or a click on the backdrop.
|
||||
- **Recently Played section on Home page**: A new "Recently Played" album row appears on the Home page between the hero carousel and the Discover section, powered by the `getAlbumList('recent')` endpoint.
|
||||
- **"Now Playing" visibility toggle in Settings**: New opt-in toggle in Settings → Behavior ("Show activity in Now Playing"). When disabled (default), `reportNowPlaying` is not called, so no activity is reported to the Navidrome "Now Playing" feed. Useful for users who share a server.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Queue cover art not updating**: After a track change the queue panel cover art often stayed on the previous album or took a long time to update. Root cause: `useCachedUrl` and `CachedImage` were not resetting their resolved URL when the `cacheKey` changed. Fixed by resetting `resolved` to `''` before each async cache fetch and basing `CachedImage`'s `loaded` state on `useEffect([cacheKey])` instead of a render-time comparison.
|
||||
- **Fullscreen Player background flickering**: The blurred background briefly showed a blank frame when switching tracks because the new image div was added to the DOM before the blob URL was ready. Fixed in `FsBg` by preloading the image via `new Image()` before inserting the layer, and using `useCachedUrl(..., false)` for the crossfade background so the raw URL is never used as a fallback during transitions.
|
||||
- **Playlist card delete confirmation not visible**: The confirm state only changed the icon colour, which was barely noticeable over the red button. Replaced with a size expansion (24 px → 30 px), an inset white ring, and a pulsing `delete-confirm-pulse` animation that alternates between two shades of red.
|
||||
- **Gruvbox Light Soft — back button and badge**: The album detail back-arrow and album badge were invisible against the warm light background. Added explicit colour overrides for `.album-detail-back` and `.album-detail-badge` in the gruvbox-light-soft theme.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`buildStreamUrl` signature**: Removed the unused `suffix` parameter. Opus transcoding (`format=flac`) is now handled in `playerStore.playTrack` via `track.suffix` check, keeping the URL builder stateless.
|
||||
|
||||
---
|
||||
|
||||
## [1.25.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Single-instance enforcement** *(reported by [@netherguy4](https://github.com/netherguy4))*: Re-launching the app while it was already running (including minimized to tray) would spawn a new independent process, leading to playback conflicts and state divergence. Integrated `tauri-plugin-single-instance` — subsequent launches are intercepted, the existing window is shown, unminimized, and focused instead.
|
||||
|
||||
---
|
||||
|
||||
## [1.25.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+22
-2
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.21.0",
|
||||
"version": "1.26.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.21.0",
|
||||
"version": "1.26.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
@@ -1493,6 +1495,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
@@ -1511,6 +1522,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-window-state": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.25.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,8 +15,10 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.25.0
|
||||
pkgver=1.28.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -16,6 +16,8 @@ makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
@@ -26,6 +28,20 @@ build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
# because the last -fuse-ld=* flag passed to cc wins.
|
||||
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
|
||||
|
||||
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
|
||||
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
|
||||
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
|
||||
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
|
||||
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
|
||||
unset CFLAGS CXXFLAGS
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
|
||||
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
|
||||
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pull release notes from GitHub
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
Generated
+381
-14
@@ -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.25.0"
|
||||
version = "1.28.0"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.25.0"
|
||||
version = "1.28.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
@@ -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"] }
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window"
|
||||
"core:webview:allow-create-webview-window",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6014,6 +6014,36 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
@@ -6254,6 +6284,60 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -6014,6 +6014,36 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
@@ -6254,6 +6284,60 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -296,12 +296,20 @@ 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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.25.0",
|
||||
"version": "1.28.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -29,12 +29,14 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": false,
|
||||
"title": "Psysonic",
|
||||
"tooltip": "Psysonic"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -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,8 +274,10 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface SubsonicSong {
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
@@ -336,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()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Play, Heart, ListPlus, X } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -67,13 +68,48 @@ export default function AlbumTrackList({
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, globalIdx);
|
||||
const to = Math.max(lastSelectedIdx, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(globalIdx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Close playlist picker on outside click
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
@@ -83,12 +119,51 @@ export default function AlbumTrackList({
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="tracklist">
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`tracklist-header${' tracklist-va'}`}>
|
||||
<div className="col-center">#</div>
|
||||
<div className="col-center">
|
||||
{inSelectMode
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
|
||||
: '#'}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
@@ -105,81 +180,88 @@ export default function AlbumTrackList({
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{discs.get(discNum)!.map((song) => {
|
||||
const globalIdx = songs.indexOf(song);
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
style={{
|
||||
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
|
||||
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
onClick={() => onPlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
@@ -21,7 +22,7 @@ function sanitizeFilename(name: string): string {
|
||||
}
|
||||
|
||||
// ── Add-to-Playlist submenu ───────────────────────────────────────
|
||||
function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
|
||||
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
@@ -84,9 +85,11 @@ function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone:
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
const subStyle: React.CSSProperties = dropDown
|
||||
? { top: 'calc(100% + 4px)', left: 0, right: 'auto' }
|
||||
: flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||
@@ -160,7 +163,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -204,16 +207,33 @@ export default function ContextMenu() {
|
||||
await action();
|
||||
};
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string) => {
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artistId);
|
||||
if (similar.length > 0) {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||
if (seedTrack) {
|
||||
// Start playback immediately based on current state
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack?.id === seedTrack.id) {
|
||||
if (!state.isPlaying) state.resume();
|
||||
// Already playing this track — don't restart
|
||||
} else {
|
||||
playTrack(seedTrack, [seedTrack]);
|
||||
}
|
||||
// Load radio queue in background
|
||||
try {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack).filter(t => t.id !== seedTrack.id);
|
||||
if (radioTracks.length > 0) enqueue(radioTracks);
|
||||
} catch (e) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
}
|
||||
} else {
|
||||
// Artist radio: no seed track, fetch in parallel and play when ready
|
||||
try {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||
if (radioTracks.length > 0) playTrack(radioTracks[0], radioTracks);
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,7 +314,7 @@ export default function ContextMenu() {
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
@@ -302,7 +322,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 +335,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 +364,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 +398,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 +439,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 +452,18 @@ export default function ContextMenu() {
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
})}>
|
||||
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
@@ -158,8 +167,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
// 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]);
|
||||
// useCachedUrl must be called unconditionally (hook rules)
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
// 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>('');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<svg width={size} height={size} viewBox="0 0 26 22" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, Radio } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -159,10 +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 currentCoverSrc = useMemo(
|
||||
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);
|
||||
@@ -177,9 +183,11 @@ export default function QueuePanel() {
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
@@ -363,12 +371,20 @@ 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>
|
||||
)}
|
||||
@@ -474,6 +490,14 @@ export default function QueuePanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<Radio size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
@@ -486,6 +510,7 @@ export default function QueuePanel() {
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||
@@ -499,8 +524,13 @@ export default function QueuePanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${track.id}-${idx}`}>
|
||||
{isFirstAutoAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
@@ -541,6 +571,7 @@ export default function QueuePanel() {
|
||||
{formatTime(track.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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 { useSidebarStore } from '../store/sidebarStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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';
|
||||
@@ -30,42 +28,6 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
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,
|
||||
@@ -83,8 +45,6 @@ export default function Sidebar({
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
@@ -93,28 +53,6 @@ export default function Sidebar({
|
||||
.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' : ''}`}>
|
||||
@@ -178,8 +116,7 @@ export default function Sidebar({
|
||||
)}
|
||||
|
||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
|
||||
{visibleSystem.map(item => (
|
||||
{visibleSystem.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+225
@@ -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',
|
||||
@@ -363,6 +376,8 @@ const enTranslation = {
|
||||
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',
|
||||
@@ -392,6 +407,7 @@ const enTranslation = {
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
@@ -428,6 +444,8 @@ const enTranslation = {
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
},
|
||||
changelog: {
|
||||
@@ -531,6 +549,8 @@ const enTranslation = {
|
||||
shuffle: 'Shuffle queue',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
autoAdded: '— Added automatically —',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
@@ -600,6 +620,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',
|
||||
@@ -609,9 +652,11 @@ const enTranslation = {
|
||||
cancel: 'Cancel',
|
||||
empty: 'No playlists yet.',
|
||||
emptyPlaylist: 'This playlist is empty.',
|
||||
addFirstSong: 'Add your first song',
|
||||
notFound: 'Playlist not found.',
|
||||
songs: '{{n}} songs',
|
||||
playAll: 'Play All',
|
||||
shuffle: 'Shuffle',
|
||||
addToQueue: 'Add to Queue',
|
||||
back: 'Back to Playlists',
|
||||
deletePlaylist: 'Delete',
|
||||
@@ -655,9 +700,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',
|
||||
@@ -729,6 +776,7 @@ const deTranslation = {
|
||||
goToArtist: 'Zum Künstler',
|
||||
download: 'Herunterladen (ZIP)',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
songInfo: 'Song-Infos',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -927,6 +975,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',
|
||||
@@ -990,6 +1048,8 @@ const deTranslation = {
|
||||
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',
|
||||
@@ -1019,6 +1079,7 @@ const deTranslation = {
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
@@ -1055,6 +1116,8 @@ const deTranslation = {
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
},
|
||||
changelog: {
|
||||
@@ -1158,6 +1221,8 @@ const deTranslation = {
|
||||
shuffle: 'Warteschlange mischen',
|
||||
gapless: 'Nahtlos',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
autoAdded: '— Automatisch hinzugefügt —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
@@ -1227,6 +1292,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',
|
||||
@@ -1236,9 +1324,11 @@ const deTranslation = {
|
||||
cancel: 'Abbrechen',
|
||||
empty: 'Noch keine Playlists.',
|
||||
emptyPlaylist: 'Diese Playlist ist leer.',
|
||||
addFirstSong: 'Ersten Song hinzufügen',
|
||||
notFound: 'Playlist nicht gefunden.',
|
||||
songs: '{{n}} Songs',
|
||||
playAll: 'Alle abspielen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
addToQueue: 'Zur Warteschlange',
|
||||
back: 'Zurück zu Playlists',
|
||||
deletePlaylist: 'Löschen',
|
||||
@@ -1282,9 +1372,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',
|
||||
@@ -1356,6 +1448,7 @@ const frTranslation = {
|
||||
goToArtist: 'Aller à l\'artiste',
|
||||
download: 'Télécharger (ZIP)',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
songInfo: 'Infos du morceau',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Retour',
|
||||
@@ -1554,6 +1647,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',
|
||||
@@ -1617,6 +1720,8 @@ const frTranslation = {
|
||||
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',
|
||||
@@ -1646,6 +1751,7 @@ 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',
|
||||
@@ -1682,6 +1788,8 @@ const frTranslation = {
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
},
|
||||
changelog: {
|
||||
@@ -1785,6 +1893,8 @@ const frTranslation = {
|
||||
shuffle: 'Mélanger la file',
|
||||
gapless: 'Sans blanc',
|
||||
crossfade: 'Fondu',
|
||||
infiniteQueue: 'File infinie',
|
||||
autoAdded: '— Ajouté automatiquement —',
|
||||
hide: 'Masquer',
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
@@ -1854,6 +1964,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',
|
||||
@@ -1863,9 +1996,11 @@ const frTranslation = {
|
||||
cancel: 'Annuler',
|
||||
empty: 'Aucune playlist pour l\'instant.',
|
||||
emptyPlaylist: 'Cette playlist est vide.',
|
||||
addFirstSong: 'Ajouter votre premier titre',
|
||||
notFound: 'Playlist introuvable.',
|
||||
songs: '{{n}} titres',
|
||||
playAll: 'Tout lire',
|
||||
shuffle: 'Aléatoire',
|
||||
addToQueue: 'Ajouter à la file',
|
||||
back: 'Retour aux playlists',
|
||||
deletePlaylist: 'Supprimer',
|
||||
@@ -1909,9 +2044,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',
|
||||
@@ -1983,6 +2120,7 @@ const nlTranslation = {
|
||||
goToArtist: 'Naar artiest',
|
||||
download: 'Downloaden (ZIP)',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
songInfo: 'Nummerinfo',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Terug',
|
||||
@@ -2181,6 +2319,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',
|
||||
@@ -2244,6 +2392,8 @@ const nlTranslation = {
|
||||
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',
|
||||
@@ -2273,6 +2423,7 @@ const nlTranslation = {
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAppearance: 'Weergave',
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
@@ -2309,6 +2460,8 @@ const nlTranslation = {
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
},
|
||||
changelog: {
|
||||
@@ -2412,6 +2565,8 @@ const nlTranslation = {
|
||||
shuffle: 'Wachtrij shufflen',
|
||||
gapless: 'Naadloos',
|
||||
crossfade: 'Overgang',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
autoAdded: '— Automatisch toegevoegd —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
nextTracks: 'Volgende nummers',
|
||||
@@ -2481,6 +2636,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',
|
||||
@@ -2490,9 +2668,11 @@ const nlTranslation = {
|
||||
cancel: 'Annuleren',
|
||||
empty: 'Nog geen playlists.',
|
||||
emptyPlaylist: 'Deze playlist is leeg.',
|
||||
addFirstSong: 'Voeg je eerste nummer toe',
|
||||
notFound: 'Playlist niet gevonden.',
|
||||
songs: '{{n}} nummers',
|
||||
playAll: 'Alles afspelen',
|
||||
shuffle: 'Willekeurig',
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
back: 'Terug naar playlists',
|
||||
deletePlaylist: 'Verwijderen',
|
||||
@@ -2536,9 +2716,11 @@ const zhTranslation = {
|
||||
playlists: '播放列表',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
starred: '个人收藏',
|
||||
recent: '最近添加',
|
||||
mostPlayed: '最常播放',
|
||||
recentlyPlayed: '最近播放',
|
||||
discover: '发现',
|
||||
loadMore: '加载更多',
|
||||
discoverMore: '发现更多',
|
||||
@@ -2610,6 +2792,7 @@ const zhTranslation = {
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
back: '返回',
|
||||
@@ -2808,6 +2991,16 @@ const zhTranslation = {
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已就绪',
|
||||
updaterInstall: '安装并重启',
|
||||
updaterDownloading: '下载中…',
|
||||
updaterInstalling: '安装中…',
|
||||
updaterDownload: '从 GitHub 下载',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -2871,6 +3064,8 @@ const zhTranslation = {
|
||||
cacheClearCancel: '取消',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
pickFolder: '选择',
|
||||
@@ -2900,6 +3095,7 @@ const zhTranslation = {
|
||||
tabPlayback: '播放',
|
||||
tabLibrary: '音乐库',
|
||||
tabAppearance: '外观',
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
@@ -2936,6 +3132,8 @@ const zhTranslation = {
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
},
|
||||
changelog: {
|
||||
@@ -3039,6 +3237,8 @@ const zhTranslation = {
|
||||
shuffle: '随机打乱队列',
|
||||
gapless: '无缝播放',
|
||||
crossfade: '交叉淡入淡出',
|
||||
infiniteQueue: '无限队列',
|
||||
autoAdded: '— 自动添加 —',
|
||||
hide: '隐藏',
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
@@ -3108,6 +3308,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: '新建播放列表',
|
||||
@@ -3117,9 +3340,11 @@ const zhTranslation = {
|
||||
cancel: '取消',
|
||||
empty: '暂无播放列表。',
|
||||
emptyPlaylist: '此播放列表为空。',
|
||||
addFirstSong: '添加第一首歌曲',
|
||||
notFound: '未找到播放列表。',
|
||||
songs: '{{n}} 首歌曲',
|
||||
playAll: '全部播放',
|
||||
shuffle: '随机播放',
|
||||
addToQueue: '添加到队列',
|
||||
back: '返回播放列表',
|
||||
deletePlaylist: '删除',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+72
-25
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -46,6 +46,7 @@ export default function ArtistDetail() {
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
@@ -57,6 +58,8 @@ export default function ArtistDetail() {
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const clearQueue = usePlayerStore(state => state.clearQueue);
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -160,18 +163,34 @@ export default function ArtistDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (topSongs.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(topSongs[0], topSongs);
|
||||
const fetchAllTracks = async () => {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
};
|
||||
|
||||
const handlePlayAll = async () => {
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks();
|
||||
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
if (topSongs.length > 0) {
|
||||
const shuffled = [...topSongs].sort(() => Math.random() - 0.5);
|
||||
clearQueue();
|
||||
playTrack(shuffled[0], shuffled);
|
||||
const handleShuffle = async () => {
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks();
|
||||
if (tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,16 +198,33 @@ export default function ArtistDetail() {
|
||||
if (!artist) return;
|
||||
setRadioLoading(true);
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artist.id, 50);
|
||||
if (similar.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(similar[0], similar);
|
||||
// Fire both fetches in parallel
|
||||
const topPromise = getTopSongs(artist.name);
|
||||
const similarPromise = getSimilarSongs2(artist.id, 50);
|
||||
|
||||
// Start playing as soon as top songs arrive
|
||||
const top = await topPromise;
|
||||
if (top.length > 0) {
|
||||
const firstTrack = songToTrack(top[0]);
|
||||
playTrack(firstTrack, [firstTrack]);
|
||||
setRadioLoading(false);
|
||||
// Enqueue remaining tracks when similar songs arrive
|
||||
const similar = await similarPromise;
|
||||
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
||||
if (remaining.length > 0) enqueue(remaining);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
// No top songs — fall back to similar
|
||||
const similar = await similarPromise;
|
||||
if (similar.length > 0) {
|
||||
const tracks = similar.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
}
|
||||
setRadioLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Radio start failed', e);
|
||||
} finally {
|
||||
setRadioLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -283,19 +319,21 @@ export default function ArtistDetail() {
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{topSongs.length > 0 && (
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll}>
|
||||
<Play size={16} /> {t('artistDetail.playAll')}
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={handleShuffle}>
|
||||
<Shuffle size={16} /> {t('artistDetail.shuffle')}
|
||||
<button className="btn btn-surface" onClick={handleShuffle} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{t('artistDetail.shuffle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -337,13 +375,22 @@ export default function ArtistDetail() {
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, topSongs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
<CachedImage
|
||||
|
||||
+12
-3
@@ -17,6 +17,8 @@ export default function Favorites() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
function removeSong(id: string) {
|
||||
@@ -105,7 +107,10 @@ export default function Favorites() {
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, songs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
@@ -124,8 +129,12 @@ export default function Favorites() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
|
||||
{i + 1}
|
||||
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
|
||||
+45
-24
@@ -4,13 +4,18 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
|
||||
export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -20,13 +25,15 @@ export default function Home() {
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([s, n, r, f, artists]) => {
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
@@ -39,7 +46,7 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent',
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
currentList: SubsonicAlbum[],
|
||||
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
|
||||
) => {
|
||||
@@ -57,7 +64,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Hero albums={heroAlbums} />
|
||||
{isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
@@ -66,19 +73,23 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
/>
|
||||
{randomArtists.length > 0 && (
|
||||
{isVisible('recent') && (
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
{isVisible('discover') && (
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
/>
|
||||
)}
|
||||
{isVisible('discoverArtists') && randomArtists.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
|
||||
@@ -96,7 +107,15 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{starred.length > 0 && (
|
||||
{isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.recentlyPlayed')}
|
||||
albums={recentlyPlayed}
|
||||
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
{isVisible('starred') && starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
albums={starred}
|
||||
@@ -104,12 +123,14 @@ export default function Home() {
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
{isVisible('mostPlayed') && (
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+160
-43
@@ -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';
|
||||
@@ -63,11 +64,49 @@ export default function PlaylistDetail() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
|
||||
|
||||
const toggleSelect = (id: string, idx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, idx);
|
||||
const to = Math.max(lastSelectedIdx, idx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(idx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
const bulkRemove = () => {
|
||||
const 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>();
|
||||
@@ -140,15 +179,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);
|
||||
}, []);
|
||||
@@ -286,6 +331,10 @@ export default function PlaylistDetail() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// ── Memoized derivations ──────────────────────────────────────
|
||||
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
|
||||
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
|
||||
|
||||
// ── Drag-over visual feedback ─────────────────────────────────
|
||||
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
@@ -307,8 +356,6 @@ export default function PlaylistDetail() {
|
||||
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
|
||||
}
|
||||
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
|
||||
@@ -347,7 +394,6 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
||||
@@ -355,7 +401,19 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(songs.map(songToTrack));
|
||||
const shuffled = [...tracks];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}}>
|
||||
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
|
||||
</button>
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(tracks);
|
||||
}}>
|
||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
||||
</button>
|
||||
@@ -394,20 +452,13 @@ export default function PlaylistDetail() {
|
||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||
)}
|
||||
{searchResults.map(song => (
|
||||
<div key={song.id} className="playlist-search-row">
|
||||
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||
<div className="playlist-search-info">
|
||||
<span className="playlist-search-title">{song.title}</span>
|
||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||
</div>
|
||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||
<button
|
||||
className="playlist-search-add-btn"
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
onClick={() => addSong(song)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -416,9 +467,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>
|
||||
@@ -429,7 +524,13 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
|
||||
{songs.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('playlists.emptyPlaylist')}</div>
|
||||
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<span>{t('playlists.emptyPlaylist')}</span>
|
||||
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
|
||||
<Search size={15} />
|
||||
{t('playlists.addFirstSong')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{songs.map((song, idx) => (
|
||||
@@ -441,13 +542,16 @@ export default function PlaylistDetail() {
|
||||
|
||||
<div
|
||||
data-track-idx={idx}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onMouseEnter={e => handleRowMouseEnter(idx, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||
onDoubleClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[idx], tracks);
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (selectedIds.size > 0) {
|
||||
toggleSelect(song.id, idx, e.shiftKey);
|
||||
} else {
|
||||
playTrack(tracks[idx], tracks);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
@@ -455,20 +559,30 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
{/* # — play on click, grip icon on hover */}
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
|
||||
onClick={() => { const tracks = songs.map(songToTrack); playTrack(tracks[idx], tracks); }}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <GripVertical size={13} />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: idx + 1}
|
||||
</div>
|
||||
{/* # — checkbox in select mode, always-visible play button otherwise */}
|
||||
{(() => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title */}
|
||||
<div className="track-info">
|
||||
@@ -569,7 +683,10 @@ export default function PlaylistDetail() {
|
||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onDoubleClick={() => addSong(song)}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addSong(song);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, X } from 'lucide-react';
|
||||
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';
|
||||
@@ -172,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>
|
||||
|
||||
|
||||
+153
-64
@@ -5,7 +5,7 @@ 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,
|
||||
GripVertical, PanelLeft, RotateCcw
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
@@ -21,6 +21,7 @@ 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';
|
||||
@@ -413,6 +414,25 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* Infinite Queue */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.infiniteQueue')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.infiniteQueueDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.infiniteQueue')}>
|
||||
<input type="checkbox" checked={auth.infiniteQueueEnabled}
|
||||
onChange={e => auth.setInfiniteQueueEnabled(e.target.checked)} id="infinite-queue-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -562,6 +582,8 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -978,6 +1000,17 @@ export default function Settings() {
|
||||
</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>
|
||||
@@ -1138,10 +1171,53 @@ function renderInline(text: string): React.ReactNode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function SidebarGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||
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 }),
|
||||
data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }),
|
||||
label,
|
||||
}));
|
||||
return (
|
||||
@@ -1156,69 +1232,103 @@ function SidebarGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
|
||||
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);
|
||||
}
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
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 };
|
||||
let parsed: { type?: string; index?: number; section?: string };
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
if (parsed.type !== 'sidebar_reorder' || parsed.index == null) 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 === null) return;
|
||||
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 next = [...itemsRef.current];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
const adjustedInsert = insertBefore > fromIdx ? insertBefore - 1 : insertBefore;
|
||||
next.splice(adjustedInsert, 0, moved);
|
||||
setItems(next);
|
||||
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);
|
||||
}, [setItems]);
|
||||
}, [libraryItems, systemItems, setItems]);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isPsyDragging || !listRef.current) return;
|
||||
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
|
||||
let target: { idx: number; before: boolean } | null = null;
|
||||
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);
|
||||
if (e.clientY < rect.top + rect.height / 2) {
|
||||
target = { idx, before: true };
|
||||
break;
|
||||
}
|
||||
target = { idx, before: false };
|
||||
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">
|
||||
@@ -1233,40 +1343,19 @@ function SidebarCustomizer() {
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ padding: '4px 0' }}
|
||||
ref={listRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{items.map((cfg, idx) => {
|
||||
const meta = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!meta) return null;
|
||||
const Icon = meta.icon;
|
||||
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
|
||||
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
|
||||
return (
|
||||
<div
|
||||
key={cfg.id}
|
||||
data-sidebar-idx={idx}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<SidebarGripHandle idx={idx} 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>
|
||||
);
|
||||
})}
|
||||
<div className="sidebar-customizer-fixed-hint">
|
||||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||||
<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>
|
||||
|
||||
@@ -33,7 +33,9 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
infiniteQueueEnabled: boolean;
|
||||
minimizeToTray: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
|
||||
@@ -65,7 +67,9 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
logout: () => void;
|
||||
@@ -98,7 +102,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
infiniteQueueEnabled: false,
|
||||
minimizeToTray: false,
|
||||
nowPlayingEnabled: false,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
isLoggedIn: false,
|
||||
@@ -163,7 +169,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
|
||||
|
||||
export interface HomeSectionConfig {
|
||||
id: HomeSectionId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
|
||||
{ id: 'hero', visible: true },
|
||||
{ id: 'recent', visible: true },
|
||||
{ id: 'discover', visible: true },
|
||||
{ id: 'discoverArtists', visible: true },
|
||||
{ id: 'recentlyPlayed', visible: true },
|
||||
{ id: 'starred', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
];
|
||||
|
||||
interface HomeStore {
|
||||
sections: HomeSectionConfig[];
|
||||
toggleSection: (id: HomeSectionId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useHomeStore = create<HomeStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sections: DEFAULT_HOME_SECTIONS,
|
||||
toggleSection: (id) => set((s) => ({
|
||||
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
|
||||
})),
|
||||
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
|
||||
}),
|
||||
{ name: 'psysonic_home' }
|
||||
)
|
||||
);
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
@@ -26,6 +26,9 @@ export interface Track {
|
||||
replayGainPeak?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
autoAdded?: boolean;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -48,6 +51,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 +117,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 +303,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 +448,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 +571,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 => {
|
||||
@@ -659,16 +672,36 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: () => {
|
||||
const { queue, queueIndex, repeatMode } = get();
|
||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||
getRandomSongs(25, currentTrack?.genre).then(songs => {
|
||||
if (songs.length === 0) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...newTracks];
|
||||
get().playTrack(newTracks[0], newQueue);
|
||||
}).catch(() => {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
});
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+172
-8
@@ -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,26 @@
|
||||
}
|
||||
|
||||
.track-num {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.track-num .bulk-check {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.track-num .bulk-check.bulk-check-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.track-row:hover .track-num .bulk-check {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Equalizer bars — shown for the currently playing track */
|
||||
.eq-bars {
|
||||
@@ -1990,6 +2087,15 @@
|
||||
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;
|
||||
@@ -4417,9 +4523,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
@@ -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 {
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
Reference in New Issue
Block a user