mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 07:45:45 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 463b7483fd | |||
| 3d11ef91a1 | |||
| c365140870 | |||
| 651b3cb050 | |||
| e2ee9247ad | |||
| 74df7b6b88 | |||
| 27a6693c8c | |||
| a932e7c2db | |||
| 7263d93d42 | |||
| 95283d792b | |||
| 53d5888ebf | |||
| cad4338324 | |||
| f9bc67cb77 | |||
| 74c75d83ca | |||
| 005abae97d | |||
| a9c20dfbdf | |||
| 0b5db172bd | |||
| bf99a64baf | |||
| 523596e414 | |||
| 2fe35e3f9b | |||
| e9fa541933 | |||
| 746aa69405 | |||
| 205b2c1914 | |||
| 0086b3e310 | |||
| 55e7cb835b | |||
| d8da511a8f |
@@ -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
|
||||
|
||||
+101
@@ -5,6 +5,107 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.30.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Bulk offline download — Playlists & Artist discographies** *(requested by [@Apollosport](https://github.com/Apollosport), [#54](https://github.com/Psychotoxical/psysonic/issues/54))*: Download an entire playlist or a full artist discography for offline use in one click. Progress is tracked per album on the Artist page ("Caching… 2/5 albums").
|
||||
- **Offline Library filter tabs**: The Offline Library now has four filter tabs — All, Albums, Playlists, and Discographies. The Discographies tab groups albums under their respective artist with section headings.
|
||||
- **Discord Rich Presence** *(requested by [@Bewenben](https://github.com/Bewenben), [#49](https://github.com/Psychotoxical/psysonic/issues/49))* (opt-in): Psysonic can now update your Discord status with the currently playing track, artist, and a live elapsed timer. Toggle in Settings → General → "Discord Rich Presence".
|
||||
- **Artist images on Artists page** *(reported by [@Apollosport](https://github.com/Apollosport), [#53](https://github.com/Psychotoxical/psysonic/issues/53))* (opt-in): Artist avatars on the Artists overview can now show the actual artist image from the server instead of the coloured initial. Toggle in Settings → General → "Show artist images". Off by default to preserve performance on large libraries.
|
||||
- **Image lazy loading**: Cover art and artist images across all pages now load lazily via `IntersectionObserver` (300 px pre-fetch margin), significantly reducing initial page render time on large libraries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crossfade triggers on manual track skip** *(reported by [@netherguy4](https://github.com/netherguy4), [#35](https://github.com/Psychotoxical/psysonic/issues/35))*: Manually clicking Next/Prev or selecting a track from the queue no longer triggers the crossfade transition. Crossfade now only fires on natural track end.
|
||||
- **Playlist offline cache showing individual album cards**: Caching a playlist offline previously created one card per album group in the Offline Library. The playlist is now stored as a single cohesive entry.
|
||||
- **Image cache abort handling**: Aborted image fetches no longer prevented the cached result from being written to IndexedDB, causing covers to reload on every page visit.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Queue tech strip**: Removed genre from the codec/bitrate overlay strip in the Queue panel — genre strings frequently caused layout overflow.
|
||||
- **"Save discography offline" label**: The Artist page offline button now reads "Save discography offline" instead of "Download discography" to avoid confusion with a ZIP export.
|
||||
- **Update toast (Win/Mac)**: The update notification now includes a disclaimer that auto-update is still in development, and always shows a direct GitHub Releases download link alongside the install button as a fallback.
|
||||
- **Facebook theme overhaul**: Improved grey text contrast, opaque album chip and back button, readable Queue/Lyrics tab labels.
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Radio: instant start + background enrichment** *(requested by [@netherguy4](https://github.com/netherguy4))*: Artist Radio now starts immediately from fast local `getTopSongs` results. `getSimilarSongs2` (Last.fm-dependent, slow) continues in the background and silently enriches the queue once it resolves — no waiting before the first song.
|
||||
- **OGG/Vorbis playback** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #42](https://github.com/Psychotoxical/psysonic/pull/42))*: Added `symphonia-format-ogg` — `.ogg` files now play natively without server-side transcoding.
|
||||
- **Click-to-seek in synced lyrics** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Clicking any line in the synced lyrics pane seeks directly to that timestamp.
|
||||
- **Volume scroll wheel** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Scrolling the mouse wheel over the volume slider adjusts volume in ±5 % steps.
|
||||
- **Lyrics visual states** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Synced lyrics lines now show three distinct visual states — active (highlighted), completed (muted), upcoming (neutral).
|
||||
- **Themed audio error toasts** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #43](https://github.com/Psychotoxical/psysonic/pull/43) / [PR #44](https://github.com/Psychotoxical/psysonic/pull/44))*: Unsupported formats and decode failures are now surfaced as themed in-app toast notifications with human-readable messages instead of silent failures.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Hopefully fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
|
||||
- **Radio queue stacking**: Clicking "Start Radio" multiple times no longer appends unlimited duplicate batches — each click replaces the pending Radio section cleanly.
|
||||
- **Start Radio keeps current song playing**: Triggering Radio while a song is playing no longer stops and restarts the current track.
|
||||
- **Radio proactive loading with songs missing `artistId`**: `getSimilarSongs2` results frequently lack `artistId`. A `currentRadioArtistId` module variable now persists the original artist ID as fallback, so proactive loading always fires correctly.
|
||||
- **Seek audio glitch after lyrics click**: Any seek ≥ 100 ms into a track no longer causes a brief fade-from-zero. `EqualPowerFadeIn` now only resets to zero-gain for seeks to the track start.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Infinite Queue: 5 tracks at a time** (was 25): Proactive loading fetches 5 tracks when ≤ 2 remain, keeping the queue lean without interruption.
|
||||
- **Queue section order is now explicit**: Manual tracks → Radio (with `— Radio —` separator) → Infinite Queue auto-added tracks (with `— Auto —` separator). Manually enqueued songs always appear before auto-managed sections.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to [@nisarg-78](https://github.com/nisarg-78) and [@JulianNymark](https://github.com/JulianNymark) for their first contributions in this release.
|
||||
Special thanks to [@netherguy4](https://github.com/netherguy4) for continued feature ideas and feedback.
|
||||
|
||||
---
|
||||
|
||||
## [1.28.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Infinite Queue** *(requested by [@netherguy4](https://github.com/netherguy4))*: When the queue runs out with Repeat off, Psysonic automatically appends 25 random tracks (optionally filtered by the last-played track's genre) so playback never stops. Toggle in Settings → Audio → "Infinite Queue". Auto-added tracks appear below a divider in the Queue panel.
|
||||
- **Start Radio plays immediately** *(requested by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" from the song/queue context menu now starts the seed track instantly while similar and top tracks load in the background — no waiting for the fetch to complete before music plays.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Single-click to play everywhere** *(reported by [@netherguy4](https://github.com/netherguy4))*: Song rows in Album Detail, Playlist Detail, Artist Detail (Top Tracks), Favorites, and Random Mix previously required a double-click. All rows now play on a single click. The track-number cell and the full row are both click targets; buttons and links inside the row still work independently.
|
||||
- **Artist page Play All / Shuffle used Top Tracks only** *(reported by [@smirnoffjr](https://github.com/smirnoffjr))*: "Play All" and "Shuffle" on the Artist detail page only sent the loaded top songs to the queue, not the full discography. Now fetches all albums in parallel and plays songs in chronological album order with correct track-number ordering within each album. Buttons show a spinner while albums are loading.
|
||||
- **Last.fm icon clipped in player bar**: The Last.fm logo button in the player bar was cut off on the right side. Fixed by correcting the SVG `viewBox` from `0 0 24 24` to `0 0 26 22` to match the actual path extents.
|
||||
- **Playlist empty state UX** *(reported by [@netherguy4](https://github.com/netherguy4))*: Empty playlists (on creation, or after deleting all tracks) now show an "Add your first song" CTA button that opens the search panel directly, rather than a plain text message with no action.
|
||||
- **Playlist search rows required "+" button click** *(reported by [@netherguy4](https://github.com/netherguy4))*: Search result rows in the song search panel now add the song on a full-row click — the separate "+" button was redundant and easy to miss.
|
||||
- **Large playlist performance**: Playlists with hundreds of songs would freeze during mouse movement. Root cause: `hoveredSongId` state triggered a full React re-render of every row on every `mouseenter`/`mouseleave` event. Fixed by removing the JS hover state and replacing it with a CSS `.track-row:hover .bulk-check` rule. Also memoized `songs.map(songToTrack)` and the `existingIds` set to avoid recomputation per render. Same fix applied to `AlbumTrackList`.
|
||||
|
||||
---
|
||||
|
||||
## [1.27.4] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **In-App Auto-Update** *(requested by [@netherguy4](https://github.com/netherguy4))*: Psysonic now checks for new releases automatically on startup (3 s delay). On macOS and Windows a native install-and-relaunch flow is available directly in the app — no browser needed. On Linux, a download link to the GitHub release page is shown instead (AppImage is not built due to WebKitGTK incompatibility with Arch/Fedora). The updater uses Tauri's signed updater plugin with minisign signatures verified against a bundled public key.
|
||||
- **Configurable Home Page**: Users can now choose which sections appear on the home page. A new "Home Page" block in Settings → Library lets you toggle each section individually (Featured, Recently Added, Discover, Discover Artists, Recently Played, Personal Favorites, Most Played) with a reset-to-default button. Hidden sections are skipped entirely.
|
||||
- **Consistent icon language** *(requested by [@netherguy4](https://github.com/netherguy4))*: Favorites (local star/heart) now use a filled Heart icon everywhere — Player Bar, Album Detail, Artist Detail, Tracklist, Context Menu. Last.fm love always uses the Last.fm logo. Previously the two were mixed up in several places.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Radio broken from context menu** *(reported by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" in the track and queue-item context menus had no effect. The handler was passing the artist name as the artist ID to `getSimilarSongs2`, which returned an empty result — so no tracks were queued and no error was shown. Now correctly passes `song.artistId`.
|
||||
- **Album Detail hero background not loading**: The blurred album art background in Album Detail only appeared after a track change, never on first visit. Root cause: `buildCoverArtUrl` was called without `useMemo`, generating a new salt on every re-render — causing `useCachedUrl` to cancel and restart its fetch endlessly. Fixed by memoising both the URL and cache key on `album.coverArt`. Same fix applied to Hero and Playlist Detail backgrounds.
|
||||
- **CI: auto-update signing pipeline**: Signing keys were not being passed correctly during the build, and macOS `.sig` files were uploaded with a generic name the manifest generator couldn't match. Fixed the post-build signing step to upload arch-specific names (`Psysonic_aarch64.app.tar.gz.sig`, `Psysonic_x64.app.tar.gz.sig`). First release where the in-app updater is fully functional on macOS and Windows.
|
||||
- **CI: Windows NSIS upload**: The release workflow was not correctly uploading Windows artifacts. Resolved by letting `tauri-action` handle NSIS bundle detection and upload directly — it only searches for what was actually built, so there is no MSI conflict with `--bundles nsis` builds.
|
||||
- **CI: npm + Cargo caching** *(contributed by [@netherguy4](https://github.com/netherguy4))*: Added `actions/cache` for npm and `Swatinem/rust-cache` for Cargo across all build jobs. Warm-cache builds will be significantly faster on subsequent releases.
|
||||
- **Linux/AUR build: ring linker error**: Builds on Arch/CachyOS failed with `rust-lld: undefined symbol: ring_core_*` after the Tauri updater was added. Arch's `rust` package bakes `-fuse-ld=lld` into the default rustflags; ring's C/asm objects are incompatible with lld. Fixed via `.cargo/config.toml` — forces `cc` as linker driver with `-fuse-ld=bfd` to override the hardcoded lld flag. Added `clang` to the AUR `makedepends` (required by ring's bindgen step).
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background flickering in Hero, Album Detail and Playlist Detail**: Blurred hero backgrounds were flickering for up to 20 seconds on first visit. Root cause: `useCachedUrl` with the default `fallbackToFetch = true` immediately returned the raw server URL, causing the background to render twice — once with the HTTP URL (triggering a server fetch) and again when the IndexedDB blob was ready. Fixed by passing `fallbackToFetch = false` in all three locations so the background only renders once the blob is cached.
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,7 +27,9 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll, line highlighting, click-to-seek, and plain-text fallback.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
@@ -37,8 +39,8 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
@@ -52,7 +54,11 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
@@ -70,22 +76,26 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
---
|
||||
|
||||
## ● Known Limitations
|
||||
|
||||
Some known bugs actively working on fixes
|
||||
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
|
||||
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
|
||||
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
|
||||
|
||||
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
|
||||
## 📦 Installation (Arch Linux / AUR)
|
||||
|
||||
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
|
||||
|
||||
| Package | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
|
||||
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
|
||||
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
|
||||
Generated
+22
-2
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.21.0",
|
||||
"version": "1.26.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.21.0",
|
||||
"version": "1.26.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
@@ -1493,6 +1495,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
@@ -1511,6 +1522,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-window-state": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.26.0",
|
||||
"version": "1.30.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,8 +15,10 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.26.0
|
||||
pkgver=1.30.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -16,6 +16,8 @@ makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
@@ -26,6 +28,20 @@ build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by tauri-plugin-updater → rustls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
# because the last -fuse-ld=* flag passed to cc wins.
|
||||
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
|
||||
|
||||
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
|
||||
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
|
||||
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
|
||||
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
|
||||
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
|
||||
unset CFLAGS CXXFLAGS
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': `Psysonic_aarch64.app.tar.gz`,
|
||||
'darwin-x86_64': `Psysonic_x64.app.tar.gz`,
|
||||
'windows-x86_64': `Psysonic_${VERSION}_x64-setup.nsis.zip`,
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pull release notes from GitHub
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
Generated
+258
-8
@@ -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"
|
||||
@@ -556,7 +565,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -922,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"
|
||||
@@ -966,6 +986,19 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1221,6 +1254,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -2397,7 +2441,10 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2536,6 +2583,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2819,6 +2872,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2834,6 +2888,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2983,6 +3049,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3032,7 +3112,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -3212,6 +3292,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
@@ -3397,9 +3483,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.25.1"
|
||||
version = "1.30.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"md5",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
@@ -3412,9 +3499,11 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3551,6 +3640,15 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3668,15 +3766,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3799,12 +3902,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -3814,6 +3930,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -3867,7 +4010,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4268,7 +4411,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4395,6 +4538,7 @@ dependencies = [
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
@@ -4491,6 +4635,18 @@ dependencies = [
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
@@ -4650,6 +4806,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4753,7 +4920,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4843,6 +5010,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -4895,6 +5072,39 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.2",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
@@ -4995,7 +5205,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5508,6 +5718,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
@@ -5786,6 +6005,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6576,6 +6804,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
@@ -6683,7 +6921,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.15",
|
||||
"zbus_macros 5.14.0",
|
||||
@@ -6822,6 +7060,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.26.0"
|
||||
version = "1.30.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -31,10 +31,13 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window"
|
||||
"core:webview:allow-create-webview-window",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -6014,6 +6014,36 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
@@ -6254,6 +6284,60 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -6014,6 +6014,36 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
@@ -6254,6 +6284,60 @@
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
+31
-10
@@ -229,9 +229,15 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Restart the fade envelope after seeking (avoids a mid-song click if
|
||||
// the user seeks to the very beginning while a fade was in progress).
|
||||
self.sample_count = 0;
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// For seeks to the very start (< 100 ms): keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
if pos.as_millis() < 100 {
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
@@ -478,13 +484,20 @@ impl SizedDecoder {
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("probe failed: {e}"))?;
|
||||
.map_err(|e| {
|
||||
let hint_str = format_hint.unwrap_or("unknown");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||||
} else {
|
||||
format!("could not open audio stream (.{hint_str}): {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let track = probed.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "no supported audio track".to_string())?;
|
||||
.ok_or_else(|| "no playable audio track found in file".to_string())?;
|
||||
|
||||
let track_id = track.id;
|
||||
let total_duration = track.codec_params.time_base
|
||||
@@ -493,7 +506,13 @@ impl SizedDecoder {
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("codec init failed: {e}"))?;
|
||||
.map_err(|e| {
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
"unsupported codec: no decoder available for this audio format".to_string()
|
||||
} else {
|
||||
format!("failed to initialise audio decoder: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
@@ -505,7 +524,7 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
break decoder.last_decoded();
|
||||
}
|
||||
Err(e) => return Err(format!("first packet: {e}")),
|
||||
Err(e) => return Err(format!("could not read audio data: {e}")),
|
||||
};
|
||||
if packet.track_id() != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
@@ -513,10 +532,10 @@ impl SizedDecoder {
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
decode_errors += 1;
|
||||
if decode_errors > DECODE_MAX_RETRIES {
|
||||
return Err("too many decode errors".into());
|
||||
return Err("too many decode errors — file may be corrupt".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("decode: {e}")),
|
||||
Err(e) => return Err(format!("audio decode error: {e}")),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1055,6 +1074,7 @@ pub async fn audio_play(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -1118,7 +1138,8 @@ pub async fn audio_play(
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// Discord Rich Presence integration.
|
||||
///
|
||||
/// To enable this feature:
|
||||
/// 1. Go to https://discord.com/developers/applications and create an application.
|
||||
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
|
||||
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
|
||||
/// (use the app icon from public/logo.png).
|
||||
///
|
||||
/// The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
/// so the app always starts cleanly regardless of Discord availability.
|
||||
|
||||
use discord_rich_presence::{
|
||||
activity::{Activity, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState(Mutex::new(None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
|
||||
client.connect().ok()?;
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
|
||||
/// the song/artist without a running timer.
|
||||
#[tauri::command]
|
||||
pub fn discord_update_presence(
|
||||
state: tauri::State<DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
elapsed_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
match try_connect() {
|
||||
Some(client) => *guard = Some(client),
|
||||
None => return Ok(()), // Discord not running — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
let state_text = format!("by {artist}");
|
||||
let large_text = album.as_deref().unwrap_or("Psysonic");
|
||||
|
||||
let assets = Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text);
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.details(&title)
|
||||
.state(&state_text)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
|
||||
// call re-connects.
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
mod discord;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
@@ -30,6 +31,50 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Restart after an in-app update.
|
||||
///
|
||||
/// `relaunch()` from tauri-plugin-process spawns the new process while the old one
|
||||
/// is still alive, so the single-instance plugin in the new process sees the old
|
||||
/// socket and kills itself immediately (endless loop).
|
||||
///
|
||||
/// This command instead:
|
||||
/// 1. Spawns a shell snippet that waits ~1.5 s and then opens the app again —
|
||||
/// by then the old process and its single-instance socket are fully gone.
|
||||
/// 2. Calls `app.exit(0)` directly, which bypasses `on_window_event`
|
||||
/// prevent_close() and releases the single-instance lock immediately.
|
||||
#[tauri::command]
|
||||
fn relaunch_after_update(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let exe_str = exe.to_string_lossy().to_string().replace('\'', "''");
|
||||
let script = format!(
|
||||
"Start-Sleep -Milliseconds 1500; Start-Process '{exe_str}'"
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-WindowStyle", "Hidden", "-NonInteractive", "-Command", &script])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
// exe lives at Psysonic.app/Contents/MacOS/psysonic — walk up to the bundle.
|
||||
let bundle = exe
|
||||
.parent() // MacOS/
|
||||
.and_then(|p| p.parent()) // Contents/
|
||||
.and_then(|p| p.parent()); // Psysonic.app
|
||||
if let Some(bundle_path) = bundle {
|
||||
let escaped = bundle_path.to_string_lossy().replace('"', "\\\"");
|
||||
let _ = std::process::Command::new("sh")
|
||||
.args(["-c", &format!("sleep 1.5 && open \"{escaped}\"")])
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
@@ -296,6 +341,9 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -492,10 +540,13 @@ pub fn run() {
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_chain_preload,
|
||||
discord::discord_update_presence,
|
||||
discord::discord_clear_presence,
|
||||
lastfm_request,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
relaunch_after_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.26.0",
|
||||
"version": "1.30.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -31,6 +31,14 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
+3
-14
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { showToast } from './utils/toast';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@@ -44,6 +45,7 @@ import Genres from './pages/Genres';
|
||||
import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
@@ -276,6 +278,7 @@ function AppShell() {
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
@@ -431,20 +434,6 @@ export default function App() {
|
||||
|
||||
const handleExport = async (since: number) => {
|
||||
setExportPickerOpen(false);
|
||||
const showToast = (text: string) => {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = text;
|
||||
toast.style.cssText = `
|
||||
position:fixed; bottom:100px; left:50%; transform:translateX(-50%);
|
||||
background:#24273a; color:#cad3f5; border:1px solid #363a4f;
|
||||
padding:10px 20px; border-radius:10px; font-size:14px;
|
||||
z-index:999999; pointer-events:none;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.5);
|
||||
white-space:nowrap;
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
};
|
||||
try {
|
||||
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
|
||||
const result = await exportNewAlbumsImage(since);
|
||||
|
||||
+12
-4
@@ -337,7 +337,6 @@ 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()}`;
|
||||
}
|
||||
|
||||
@@ -396,9 +395,18 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
|
||||
return data.playlist;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
|
||||
if (songIds.length > 0) {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
} else if (prevCount > 0) {
|
||||
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
|
||||
// Use updatePlaylist.view with explicit index removal to clear the list instead.
|
||||
await api('updatePlaylist.view', {
|
||||
playlistId: id,
|
||||
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -143,8 +143,8 @@ export default function AlbumHeader({
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
data-tooltip="Vergrößern"
|
||||
aria-label={`${info.name} Cover vergrößern`}
|
||||
data-tooltip={t('albumDetail.enlargeCover')}
|
||||
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
</button>
|
||||
@@ -203,7 +203,7 @@ export default function AlbumHeader({
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Play, Star, ListPlus, X } from 'lucide-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';
|
||||
@@ -68,7 +68,6 @@ 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();
|
||||
@@ -187,12 +186,12 @@ export default function AlbumTrackList({
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onClick={e => {
|
||||
if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
@@ -220,27 +219,16 @@ export default function AlbumTrackList({
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (inSelectMode || hoveredSongId === song.id) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
|
||||
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: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? globalIdx + 1)}
|
||||
<span 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">
|
||||
@@ -256,7 +244,7 @@ export default function AlbumTrackList({
|
||||
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))' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { check, Update, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, Download, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'available'; version: string; update: Update | null }
|
||||
| { phase: 'downloading'; pct: number }
|
||||
| { phase: 'installing' }
|
||||
| { phase: 'done' };
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<State>({ phase: 'idle' });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
// Try Tauri native updater first (macOS + Windows)
|
||||
const update = await check();
|
||||
if (cancelled) return;
|
||||
if (update) {
|
||||
setState({ phase: 'available', version: update.version, update });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Tauri updater unavailable or network error — fall through to GitHub check
|
||||
}
|
||||
// Fallback: GitHub API check (Linux / offline Tauri updater)
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setState({ phase: 'available', version: tag.replace(/^[^0-9]*/, ''), update: null });
|
||||
}
|
||||
} catch {
|
||||
// No update check possible — stay idle
|
||||
}
|
||||
}, 3000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
if (dismissed || state.phase === 'idle' || state.phase === 'done') return null;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (state.phase !== 'available' || !state.update) return;
|
||||
const update = state.update;
|
||||
const savedVersion = state.version;
|
||||
let total = 0;
|
||||
let downloaded = 0;
|
||||
setState({ phase: 'downloading', pct: 0 });
|
||||
try {
|
||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setState({ phase: 'downloading', pct: total ? Math.round((downloaded / total) * 100) : 0 });
|
||||
} else if (event.event === 'Finished') {
|
||||
setState({ phase: 'installing' });
|
||||
}
|
||||
});
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e) {
|
||||
console.error('Update failed', e);
|
||||
setState({ phase: 'available', version: savedVersion, update });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
open('https://github.com/Psychotoxical/psysonic/releases/latest');
|
||||
};
|
||||
|
||||
const version = state.phase === 'available' ? state.version : '';
|
||||
const canInstall = state.phase === 'available' && state.update !== null;
|
||||
const isLinuxFallback = state.phase === 'available' && state.update === null;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version })}</div>
|
||||
|
||||
{state.phase === 'downloading' && (
|
||||
<div className="app-updater-progress-wrap">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${state.pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{state.pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'installing' && (
|
||||
<div className="app-updater-status">{t('common.updaterInstalling')}</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{canInstall && (
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
<button className="app-updater-btn-primary" onClick={handleInstall}>
|
||||
<Download size={12} /> {t('common.updaterInstall')}
|
||||
</button>
|
||||
<button className="app-updater-btn-secondary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLinuxFallback && (
|
||||
<button className="app-updater-btn-primary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -16,16 +16,33 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setResolved('');
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
||||
if (!controller.signal.aborted) setResolved(url);
|
||||
});
|
||||
return () => { controller.abort(); };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||
const [inView, setInView] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
||||
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
@@ -36,7 +53,8 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
ref={imgRef}
|
||||
src={resolvedSrc || undefined}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus, Info } 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';
|
||||
@@ -206,16 +207,73 @@ export default function ContextMenu() {
|
||||
await action();
|
||||
};
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string) => {
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artistId);
|
||||
if (similar.length > 0) {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||
if (seedTrack) {
|
||||
// Start playback immediately based on current state
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack?.id === seedTrack.id) {
|
||||
if (!state.isPlaying) state.resume();
|
||||
// Already playing this track — don't restart
|
||||
} else {
|
||||
playTrack(seedTrack, [seedTrack]);
|
||||
}
|
||||
// Load radio queue in background — enqueueRadio replaces any pending radio
|
||||
// tracks so clicking "Start Radio" again never stacks duplicate batches.
|
||||
try {
|
||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||
const radioTracks = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedTrack.id)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
}
|
||||
} else {
|
||||
// Artist radio: fire both calls immediately but don't wait for the slow one.
|
||||
// getTopSongs is fast (local library) — start playback as soon as it resolves.
|
||||
// getSimilarSongs2 is slow (Last.fm) — enrich the queue in the background.
|
||||
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
|
||||
try {
|
||||
const top = await getTopSongs(artistName);
|
||||
const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
if (topTracks.length === 0) {
|
||||
// No local top songs — fall back to waiting for similar tracks
|
||||
const similar = await similarPromise;
|
||||
const fallback = similar.map(t => ({ ...songToTrack(t), radioAdded: true as const }));
|
||||
if (fallback.length === 0) return;
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(fallback, artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(fallback[0], fallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Start playback immediately from top songs
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(topTracks, artistId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
playTrack(topTracks[0], topTracks);
|
||||
}
|
||||
// Enrich with similar tracks in the background
|
||||
similarPromise.then(similar => {
|
||||
const similarTracks = similar
|
||||
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||
.filter(t => !topTracks.some(top => top.id === t.id));
|
||||
if (similarTracks.length === 0) return;
|
||||
// Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them
|
||||
// together with the new similar tracks rather than losing them.
|
||||
const { queue, queueIndex } = usePlayerStore.getState();
|
||||
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
|
||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,7 +354,7 @@ export default function ContextMenu() {
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
@@ -304,7 +362,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
@@ -317,7 +375,7 @@ 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>
|
||||
);
|
||||
@@ -346,7 +404,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(album.id, !starred);
|
||||
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
@@ -380,7 +438,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(artist.id, !starred);
|
||||
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
</>
|
||||
@@ -421,7 +479,7 @@ export default function ContextMenu() {
|
||||
setStarredOverride(song.id, !starred);
|
||||
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||
})}>
|
||||
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
@@ -434,12 +492,12 @@ 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" />
|
||||
|
||||
@@ -245,11 +245,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<svg width={size} height={size} viewBox="0 0 26 22" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
|
||||
const hasSynced = syncedLines !== null && syncedLines.length > 0;
|
||||
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const prevActive = useRef(-1);
|
||||
@@ -103,6 +105,13 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const getLyricLineClass = (i: number, active: number) => {
|
||||
const base = 'lyrics-line';
|
||||
if (i > active) return base;
|
||||
if (i < active) return `${base} completed`;
|
||||
return `${base} active`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
@@ -113,7 +122,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`lyrics-line${i === activeIdx ? ' active' : ''}`}
|
||||
className={getLyricLineClass(i, activeIdx)}
|
||||
onClick={() => { if (duration > 0) seek(line.time / duration); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, Star, MicVocal
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -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';
|
||||
@@ -63,6 +64,12 @@ export default function PlayerBar() {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [setVolume]);
|
||||
|
||||
const handleVolumeWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
setVolume(Math.max(0, Math.min(1, volume + delta)));
|
||||
}, [volume, setVolume]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
@@ -115,9 +122,9 @@ export default function PlayerBar() {
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
style={{ color: isStarred ? 'var(--ctp-yellow)' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Star size={15} fill={isStarred ? 'var(--ctp-yellow)' : 'none'} />
|
||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{currentTrack && lastfmSessionKey && (
|
||||
@@ -128,7 +135,7 @@ export default function PlayerBar() {
|
||||
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Heart size={15} fill={lastfmLoved ? '#e31c23' : 'none'} />
|
||||
<LastfmIcon size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -138,7 +145,7 @@ export default function PlayerBar() {
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
@@ -149,7 +156,7 @@ export default function PlayerBar() {
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
@@ -202,7 +209,7 @@ export default function PlayerBar() {
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<div className="player-volume-slider-wrap">
|
||||
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
|
||||
{showVolPct && (
|
||||
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
||||
{Math.round(volume * 100)}%
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
@@ -183,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);
|
||||
@@ -369,10 +371,9 @@ export default function QueuePanel() {
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
{(currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
<div className="queue-current-tech">
|
||||
{[
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
@@ -488,6 +489,14 @@ export default function QueuePanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<ArrowUpToLine size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
@@ -500,6 +509,8 @@ export default function QueuePanel() {
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||
@@ -513,8 +524,18 @@ export default function QueuePanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${track.id}-${idx}`}>
|
||||
{isFirstRadioAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isFirstAutoAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
@@ -555,6 +576,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}
|
||||
|
||||
+161
@@ -28,6 +28,7 @@ const enTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
starred: 'Personal Favorites',
|
||||
recent: 'Recently Added',
|
||||
mostPlayed: 'Most Played',
|
||||
@@ -139,6 +140,7 @@ const enTranslation = {
|
||||
bioModal: 'Artist Biography',
|
||||
bioClose: 'Close',
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
@@ -164,6 +166,9 @@ const enTranslation = {
|
||||
openedInBrowser: 'Opened in browser',
|
||||
featuredOn: 'Also Featured On',
|
||||
similarArtists: 'Similar Artists',
|
||||
cacheOffline: 'Save discography offline',
|
||||
offlineCached: 'Discography cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -236,6 +241,8 @@ const enTranslation = {
|
||||
all: 'All',
|
||||
gridView: 'Grid view',
|
||||
listView: 'List view',
|
||||
imagesOn: 'Artist images on — may increase network and system load',
|
||||
imagesOff: 'Artist images off — showing initials only',
|
||||
loadMore: 'Load more',
|
||||
notFound: 'No artists found.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
@@ -274,6 +281,10 @@ const enTranslation = {
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'All',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
@@ -306,6 +317,13 @@ const enTranslation = {
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||
bulkClear: 'Clear selection',
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is ready',
|
||||
updaterInstall: 'Install & Restart',
|
||||
updaterDownloading: 'Downloading…',
|
||||
updaterInstalling: 'Installing…',
|
||||
updaterDownload: 'Download from GitHub',
|
||||
updaterExperimentalHint: 'Auto-update is still in development',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -367,8 +385,12 @@ const enTranslation = {
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
downloadsTitle: 'Download Folder',
|
||||
@@ -387,6 +409,7 @@ const enTranslation = {
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Contributors',
|
||||
aboutSpecialThanksLabel: 'Special Thanks',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
|
||||
@@ -400,6 +423,7 @@ const enTranslation = {
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
@@ -436,6 +460,8 @@ const enTranslation = {
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
},
|
||||
changelog: {
|
||||
@@ -539,6 +565,9 @@ const enTranslation = {
|
||||
shuffle: 'Shuffle queue',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
autoAdded: '— Added automatically —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
@@ -640,6 +669,7 @@ 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',
|
||||
@@ -657,6 +687,9 @@ const enTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
addSong: 'Add to playlist',
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -687,6 +720,7 @@ const deTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
starred: 'Persönliche Favoriten',
|
||||
recent: 'Zuletzt hinzugefügt',
|
||||
mostPlayed: 'Meistgehört',
|
||||
@@ -798,6 +832,7 @@ const deTranslation = {
|
||||
bioModal: 'Künstler-Biografie',
|
||||
bioClose: 'Schließen',
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -823,6 +858,9 @@ const deTranslation = {
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
featuredOn: 'Auch enthalten auf',
|
||||
similarArtists: 'Ähnliche Künstler',
|
||||
cacheOffline: 'Diskografie offline speichern',
|
||||
offlineCached: 'Diskografie gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -895,6 +933,8 @@ const deTranslation = {
|
||||
all: 'Alle',
|
||||
gridView: 'Gitteransicht',
|
||||
listView: 'Listenansicht',
|
||||
imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen',
|
||||
imagesOff: 'Künstlerbilder deaktiviert — zeigt nur Initialen',
|
||||
loadMore: 'Mehr laden',
|
||||
notFound: 'Keine Künstler gefunden.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
@@ -933,6 +973,10 @@ const deTranslation = {
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
offlineAlbumCount_plural: '{{n}} Alben',
|
||||
offlineFilterAll: 'Alle',
|
||||
offlineFilterAlbums: 'Alben',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
@@ -965,6 +1009,13 @@ const deTranslation = {
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||
bulkClear: 'Auswahl aufheben',
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} ist bereit',
|
||||
updaterInstall: 'Installieren & Neustart',
|
||||
updaterDownloading: 'Wird geladen…',
|
||||
updaterInstalling: 'Wird installiert…',
|
||||
updaterDownload: 'Von GitHub herunterladen',
|
||||
updaterExperimentalHint: 'Automatische Updates sind noch in Entwicklung',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -1026,8 +1077,12 @@ const deTranslation = {
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
@@ -1046,6 +1101,7 @@ const deTranslation = {
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
aboutContributorsLabel: 'Mitwirkende',
|
||||
aboutSpecialThanksLabel: 'Besonderer Dank',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
|
||||
@@ -1059,6 +1115,7 @@ const deTranslation = {
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
@@ -1095,6 +1152,8 @@ const deTranslation = {
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
},
|
||||
changelog: {
|
||||
@@ -1198,6 +1257,9 @@ const deTranslation = {
|
||||
shuffle: 'Warteschlange mischen',
|
||||
gapless: 'Nahtlos',
|
||||
crossfade: 'Crossfade',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
autoAdded: '— Automatisch hinzugefügt —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
@@ -1299,6 +1361,7 @@ 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',
|
||||
@@ -1316,6 +1379,9 @@ const deTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
addSong: 'Zur Playlist hinzufügen',
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1346,6 +1412,7 @@ const frTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
starred: 'Favoris personnels',
|
||||
recent: 'Ajoutés récemment',
|
||||
mostPlayed: 'Les plus écoutés',
|
||||
@@ -1457,6 +1524,7 @@ const frTranslation = {
|
||||
bioModal: 'Biographie de l\'artiste',
|
||||
bioClose: 'Fermer',
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
@@ -1482,6 +1550,9 @@ const frTranslation = {
|
||||
openedInBrowser: 'Ouvert dans le navigateur',
|
||||
featuredOn: 'Également sur',
|
||||
similarArtists: 'Artistes similaires',
|
||||
cacheOffline: 'Enregistrer la discographie hors ligne',
|
||||
offlineCached: 'Discographie en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoris',
|
||||
@@ -1554,6 +1625,8 @@ const frTranslation = {
|
||||
all: 'Tous',
|
||||
gridView: 'Vue en grille',
|
||||
listView: 'Vue en liste',
|
||||
imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système',
|
||||
imagesOff: 'Images d\'artistes désactivées — affichage des initiales uniquement',
|
||||
loadMore: 'Charger plus',
|
||||
notFound: 'Aucun artiste trouvé.',
|
||||
albumCount_one: '{{count}} album',
|
||||
@@ -1592,6 +1665,10 @@ const frTranslation = {
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Tout',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
@@ -1624,6 +1701,13 @@ const frTranslation = {
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||
bulkClear: 'Désélectionner',
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est prête',
|
||||
updaterInstall: 'Installer & Redémarrer',
|
||||
updaterDownloading: 'Téléchargement…',
|
||||
updaterInstalling: 'Installation…',
|
||||
updaterDownload: 'Télécharger depuis GitHub',
|
||||
updaterExperimentalHint: 'La mise à jour automatique est encore en développement',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -1685,8 +1769,12 @@ const frTranslation = {
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
downloadsTitle: 'Dossier de téléchargement',
|
||||
@@ -1705,6 +1793,7 @@ const frTranslation = {
|
||||
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
||||
aboutContributorsLabel: 'Contributeurs',
|
||||
aboutSpecialThanksLabel: 'Remerciements spéciaux',
|
||||
changelog: 'Journal des modifications',
|
||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
|
||||
@@ -1718,6 +1807,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',
|
||||
@@ -1754,6 +1844,8 @@ const frTranslation = {
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
},
|
||||
changelog: {
|
||||
@@ -1857,6 +1949,9 @@ const frTranslation = {
|
||||
shuffle: 'Mélanger la file',
|
||||
gapless: 'Sans blanc',
|
||||
crossfade: 'Fondu',
|
||||
infiniteQueue: 'File infinie',
|
||||
autoAdded: '— Ajouté automatiquement —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Masquer',
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
@@ -1958,6 +2053,7 @@ 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',
|
||||
@@ -1975,6 +2071,9 @@ const frTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
addSong: 'Ajouter à la playlist',
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2005,6 +2104,7 @@ const nlTranslation = {
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
starred: 'Persoonlijke favorieten',
|
||||
recent: 'Recent toegevoegd',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
@@ -2116,6 +2216,7 @@ const nlTranslation = {
|
||||
bioModal: 'Artiest biografie',
|
||||
bioClose: 'Sluiten',
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
@@ -2141,6 +2242,9 @@ const nlTranslation = {
|
||||
openedInBrowser: 'Geopend in browser',
|
||||
featuredOn: 'Ook te vinden op',
|
||||
similarArtists: 'Vergelijkbare artiesten',
|
||||
cacheOffline: 'Discografie offline opslaan',
|
||||
offlineCached: 'Discografie gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorieten',
|
||||
@@ -2213,6 +2317,8 @@ const nlTranslation = {
|
||||
all: 'Alle',
|
||||
gridView: 'Rasterweergave',
|
||||
listView: 'Lijstweergave',
|
||||
imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen',
|
||||
imagesOff: 'Artiestafbeeldingen uit — toont alleen initialen',
|
||||
loadMore: 'Meer laden',
|
||||
notFound: 'Geen artiesten gevonden.',
|
||||
albumCount_one: '{{count}} album',
|
||||
@@ -2251,6 +2357,10 @@ const nlTranslation = {
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Alles',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Afspeellijsten',
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
@@ -2283,6 +2393,13 @@ const nlTranslation = {
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||
bulkClear: 'Selectie wissen',
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} is klaar',
|
||||
updaterInstall: 'Installeren & Herstarten',
|
||||
updaterDownloading: 'Downloaden…',
|
||||
updaterInstalling: 'Installeren…',
|
||||
updaterDownload: 'Downloaden van GitHub',
|
||||
updaterExperimentalHint: 'Automatisch bijwerken is nog in ontwikkeling',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -2344,8 +2461,12 @@ const nlTranslation = {
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
downloadsTitle: 'Downloadmap',
|
||||
@@ -2364,6 +2485,7 @@ const nlTranslation = {
|
||||
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
||||
aboutContributorsLabel: 'Bijdragers',
|
||||
aboutSpecialThanksLabel: 'Speciale dank',
|
||||
changelog: 'Wijzigingslog',
|
||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
|
||||
@@ -2377,6 +2499,7 @@ const nlTranslation = {
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAppearance: 'Weergave',
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
@@ -2413,6 +2536,8 @@ const nlTranslation = {
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
},
|
||||
changelog: {
|
||||
@@ -2516,6 +2641,9 @@ const nlTranslation = {
|
||||
shuffle: 'Wachtrij shufflen',
|
||||
gapless: 'Naadloos',
|
||||
crossfade: 'Overgang',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
autoAdded: '— Automatisch toegevoegd —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
nextTracks: 'Volgende nummers',
|
||||
@@ -2617,6 +2745,7 @@ 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',
|
||||
@@ -2634,6 +2763,9 @@ const nlTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
addSong: 'Toevoegen aan playlist',
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2664,6 +2796,7 @@ const zhTranslation = {
|
||||
playlists: '播放列表',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
starred: '个人收藏',
|
||||
recent: '最近添加',
|
||||
mostPlayed: '最常播放',
|
||||
@@ -2775,6 +2908,7 @@ const zhTranslation = {
|
||||
bioModal: '艺术家简介',
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
@@ -2800,6 +2934,9 @@ const zhTranslation = {
|
||||
openedInBrowser: '已在浏览器中打开',
|
||||
featuredOn: '还出现在',
|
||||
similarArtists: '相似艺术家',
|
||||
cacheOffline: '离线保存全部专辑',
|
||||
offlineCached: '全部专辑已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
},
|
||||
favorites: {
|
||||
title: '收藏夹',
|
||||
@@ -2872,6 +3009,8 @@ const zhTranslation = {
|
||||
all: '全部',
|
||||
gridView: '网格视图',
|
||||
listView: '列表视图',
|
||||
imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载',
|
||||
imagesOff: '艺术家图片已关闭 — 仅显示首字母',
|
||||
loadMore: '加载更多',
|
||||
notFound: '未找到艺术家。',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
@@ -2942,6 +3081,13 @@ const zhTranslation = {
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已就绪',
|
||||
updaterInstall: '安装并重启',
|
||||
updaterDownloading: '下载中…',
|
||||
updaterInstalling: '安装中…',
|
||||
updaterDownload: '从 GitHub 下载',
|
||||
updaterExperimentalHint: '自动更新功能仍在开发中',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -3003,8 +3149,12 @@ const zhTranslation = {
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
@@ -3023,6 +3173,7 @@ const zhTranslation = {
|
||||
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
||||
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
||||
aboutContributorsLabel: '贡献者',
|
||||
aboutSpecialThanksLabel: '特别感谢',
|
||||
changelog: '更新日志',
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
|
||||
@@ -3036,6 +3187,7 @@ const zhTranslation = {
|
||||
tabPlayback: '播放',
|
||||
tabLibrary: '音乐库',
|
||||
tabAppearance: '外观',
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
@@ -3072,6 +3224,8 @@ const zhTranslation = {
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
},
|
||||
changelog: {
|
||||
@@ -3175,6 +3329,9 @@ const zhTranslation = {
|
||||
shuffle: '随机打乱队列',
|
||||
gapless: '无缝播放',
|
||||
crossfade: '交叉淡入淡出',
|
||||
infiniteQueue: '无限队列',
|
||||
autoAdded: '— 自动添加 —',
|
||||
radioAdded: '— 收音机 —',
|
||||
hide: '隐藏',
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
@@ -3276,6 +3433,7 @@ const zhTranslation = {
|
||||
cancel: '取消',
|
||||
empty: '暂无播放列表。',
|
||||
emptyPlaylist: '此播放列表为空。',
|
||||
addFirstSong: '添加第一首歌曲',
|
||||
notFound: '未找到播放列表。',
|
||||
songs: '{{n}} 首歌曲',
|
||||
playAll: '全部播放',
|
||||
@@ -3293,6 +3451,9 @@ const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
@@ -32,6 +32,7 @@ export default function AlbumDetail() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -201,12 +202,14 @@ const handleEnqueueAll = () => {
|
||||
const next = new Set(starredSongs);
|
||||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||||
setStarredSongs(next);
|
||||
setStarredOverride(song.id, !wasStarred);
|
||||
try {
|
||||
if (wasStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
setStarredOverride(song.id, wasStarred);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,9 +234,12 @@ const handleEnqueueAll = () => {
|
||||
deleteAlbum(album.album.id, serverId);
|
||||
};
|
||||
|
||||
// Hooks must be called unconditionally — derive from nullable album state
|
||||
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
|
||||
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
|
||||
// Hooks must be called unconditionally — derive from nullable album state.
|
||||
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
||||
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
|
||||
// which cancels and restarts the useCachedUrl effect → background never resolves.
|
||||
const coverUrl = useMemo(() => album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
|
||||
+98
-25
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
@@ -46,6 +48,7 @@ export default function ArtistDetail() {
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
@@ -57,6 +60,10 @@ export default function ArtistDetail() {
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const clearQueue = usePlayerStore(state => state.clearQueue);
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||
const { downloadArtist, bulkProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -160,18 +167,34 @@ export default function ArtistDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (topSongs.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(topSongs[0], topSongs);
|
||||
const fetchAllTracks = async () => {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
};
|
||||
|
||||
const handlePlayAll = async () => {
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks();
|
||||
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
if (topSongs.length > 0) {
|
||||
const shuffled = [...topSongs].sort(() => Math.random() - 0.5);
|
||||
clearQueue();
|
||||
playTrack(shuffled[0], shuffled);
|
||||
const handleShuffle = async () => {
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks();
|
||||
if (tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,16 +202,33 @@ export default function ArtistDetail() {
|
||||
if (!artist) return;
|
||||
setRadioLoading(true);
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artist.id, 50);
|
||||
if (similar.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(similar[0], similar);
|
||||
// Fire both fetches in parallel
|
||||
const topPromise = getTopSongs(artist.name);
|
||||
const similarPromise = getSimilarSongs2(artist.id, 50);
|
||||
|
||||
// Start playing as soon as top songs arrive
|
||||
const top = await topPromise;
|
||||
if (top.length > 0) {
|
||||
const firstTrack = songToTrack(top[0]);
|
||||
playTrack(firstTrack, [firstTrack]);
|
||||
setRadioLoading(false);
|
||||
// Enqueue remaining tracks when similar songs arrive
|
||||
const similar = await similarPromise;
|
||||
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
||||
if (remaining.length > 0) enqueue(remaining);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
// No top songs — fall back to similar
|
||||
const similar = await similarPromise;
|
||||
if (similar.length > 0) {
|
||||
const tracks = similar.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
}
|
||||
setRadioLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Radio start failed', e);
|
||||
} finally {
|
||||
setRadioLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -283,19 +323,21 @@ export default function ArtistDetail() {
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{topSongs.length > 0 && (
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll}>
|
||||
<Play size={16} /> {t('artistDetail.playAll')}
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={handleShuffle}>
|
||||
<Shuffle size={16} /> {t('artistDetail.shuffle')}
|
||||
<button className="btn btn-surface" onClick={handleShuffle} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{t('artistDetail.shuffle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -303,6 +345,28 @@ export default function ArtistDetail() {
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
|
||||
</button>
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,13 +401,22 @@ export default function ArtistDetail() {
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, topSongs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
<CachedImage
|
||||
|
||||
+98
-55
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist } from '../api/subsonic';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ALL_SENTINEL = 'ALL';
|
||||
@@ -23,12 +25,52 @@ function nameColor(name: string): string {
|
||||
}
|
||||
|
||||
function nameInitial(name: string): string {
|
||||
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
|
||||
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
|
||||
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
|
||||
const letter = name.match(/\p{L}/u)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
// Fallback: first alphanumeric (e.g. "1349")
|
||||
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
|
||||
return alnum?.toUpperCase() ?? '?';
|
||||
const alnum = name.match(/[0-9]/)?.[0];
|
||||
return alnum ?? '?';
|
||||
}
|
||||
|
||||
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && artist.coverArt) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 300)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
|
||||
alt={artist.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && artist.coverArt) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 64)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Artists() {
|
||||
@@ -42,6 +84,8 @@ export default function Artists() {
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
@@ -101,6 +145,15 @@ export default function Artists() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
@@ -146,30 +199,25 @@ export default function Artists() {
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(artist => {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,31 +227,26 @@ export default function Artists() {
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 className="letter-heading">{letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{groups[letter].map(artist => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+21
-8
@@ -17,10 +17,15 @@ export default function Favorites() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
function removeSong(id: string) {
|
||||
unstar(id, 'song').catch(() => {});
|
||||
setStarredOverride(id, false);
|
||||
setSongs(prev => prev.filter(s => s.id !== id));
|
||||
}
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
@@ -45,7 +50,8 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0;
|
||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
@@ -65,14 +71,14 @@ export default function Favorites() {
|
||||
<AlbumRow title={t('favorites.albums')} albums={albums} />
|
||||
)}
|
||||
|
||||
{songs.length > 0 && (
|
||||
{visibleSongs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}
|
||||
>
|
||||
@@ -82,7 +88,7 @@ export default function Favorites() {
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
@@ -98,14 +104,17 @@ export default function Favorites() {
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div />
|
||||
</div>
|
||||
{songs.map((song, i) => {
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
@@ -124,8 +133,12 @@ export default function Favorites() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
|
||||
{i + 1}
|
||||
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
|
||||
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
|
||||
+33
-23
@@ -4,8 +4,12 @@ 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[]>([]);
|
||||
@@ -22,7 +26,7 @@ export default function Home() {
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
@@ -60,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 ? (
|
||||
@@ -69,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>
|
||||
@@ -99,7 +107,7 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{recentlyPlayed.length > 0 && (
|
||||
{isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.recentlyPlayed')}
|
||||
albums={recentlyPlayed}
|
||||
@@ -107,7 +115,7 @@ export default function Home() {
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
{starred.length > 0 && (
|
||||
{isVisible('starred') && starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
albums={starred}
|
||||
@@ -115,12 +123,14 @@ export default function Home() {
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
{isVisible('mostPlayed') && (
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, Star, ExternalLink, MicVocal } from 'lucide-react';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import {
|
||||
@@ -296,7 +296,7 @@ export default function NowPlaying() {
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
<Heart size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
<button
|
||||
className="np-star-btn"
|
||||
|
||||
+129
-69
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -7,6 +7,8 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
|
||||
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
||||
|
||||
export default function OfflineLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -15,26 +17,36 @@ export default function OfflineLibrary() {
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
const countByType = (type: FilterType) => {
|
||||
if (type === 'all') return albums.length;
|
||||
return albums.filter(a => (a.type ?? 'album') === type).length;
|
||||
};
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? albums
|
||||
: albums.filter(a => (a.type ?? 'album') === filter);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (albumId: string) => {
|
||||
const tracks = buildTracks(albumId);
|
||||
@@ -45,6 +57,84 @@ const buildTracks = (albumId: string) => {
|
||||
enqueue(buildTracks(albumId));
|
||||
};
|
||||
|
||||
const renderCard = (album: typeof albums[0]) => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={`${album.serverId}:${album.id}`} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
data-tooltip={t('queue.addToQueue')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// For artist filter: group by artist name
|
||||
const renderArtistGroups = () => {
|
||||
const groups: Record<string, typeof albums> = {};
|
||||
for (const album of filtered) {
|
||||
const key = album.artist || '—';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(album);
|
||||
}
|
||||
const sortedArtists = Object.keys(groups).sort((a, b) => a.localeCompare(b));
|
||||
return sortedArtists.map(artistName => (
|
||||
<div key={artistName} className="offline-artist-group">
|
||||
<h2 className="offline-artist-group-heading">{artistName}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{groups[artistName].map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const TABS: { id: FilterType; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'connection.offlineFilterAll' },
|
||||
{ id: 'album', labelKey: 'connection.offlineFilterAlbums' },
|
||||
{ id: 'playlist', labelKey: 'connection.offlineFilterPlaylists' },
|
||||
{ id: 'artist', labelKey: 'connection.offlineFilterArtists' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="offline-library animate-fade-in">
|
||||
<div className="offline-library-header">
|
||||
@@ -57,60 +147,30 @@ const buildTracks = (albumId: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<div className="offline-filter-tabs">
|
||||
{TABS.map(tab => {
|
||||
const count = countByType(tab.id);
|
||||
if (tab.id !== 'all' && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`offline-filter-tab${filter === tab.id ? ' active' : ''}`}
|
||||
onClick={() => setFilter(tab.id)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
<span className="offline-filter-tab-count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
|
||||
) : filter === 'artist' ? (
|
||||
renderArtistGroups()
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(album => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={album.id} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
title="Zur Warteschlange hinzufügen"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, Shuffle } from 'lucide-react';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, search, setRating, star, unstar,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
@@ -54,9 +56,11 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const { startDrag, isDragging } = useDragDrop();
|
||||
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
|
||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
@@ -64,7 +68,6 @@ 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);
|
||||
@@ -93,9 +96,10 @@ export default function PlaylistDetail() {
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
const bulkRemove = () => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter(s => !selectedIds.has(s.id));
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
savePlaylist(next, prevCount);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
@@ -205,11 +209,11 @@ export default function PlaylistDetail() {
|
||||
}, [playlist?.id]);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id));
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
|
||||
if (id) touchPlaylist(id);
|
||||
} catch {}
|
||||
setSaving(false);
|
||||
@@ -217,9 +221,10 @@ export default function PlaylistDetail() {
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter((_, i) => i !== idx);
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
savePlaylist(next, prevCount);
|
||||
};
|
||||
|
||||
// ── Add ───────────────────────────────────────────────────────
|
||||
@@ -240,12 +245,13 @@ export default function PlaylistDetail() {
|
||||
|
||||
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const isStarred = starredSongs.has(song.id);
|
||||
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
setStarredSongs(prev => {
|
||||
const next = new Set(prev);
|
||||
isStarred ? next.delete(song.id) : next.add(song.id);
|
||||
return next;
|
||||
});
|
||||
setStarredOverride(song.id, !isStarred);
|
||||
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -332,6 +338,10 @@ export default function PlaylistDetail() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// ── Memoized derivations ──────────────────────────────────────
|
||||
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
|
||||
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
|
||||
|
||||
// ── Drag-over visual feedback ─────────────────────────────────
|
||||
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
@@ -353,8 +363,6 @@ export default function PlaylistDetail() {
|
||||
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
|
||||
}
|
||||
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
|
||||
@@ -393,7 +401,6 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
||||
@@ -401,7 +408,6 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const tracks = songs.map(songToTrack);
|
||||
const shuffled = [...tracks];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
@@ -414,7 +420,7 @@ export default function PlaylistDetail() {
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(songs.map(songToTrack));
|
||||
enqueue(tracks);
|
||||
}}>
|
||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
||||
</button>
|
||||
@@ -425,6 +431,25 @@ export default function PlaylistDetail() {
|
||||
>
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
{songs.length > 0 && id && (() => {
|
||||
const isDownloading = isAlbumDownloading(id);
|
||||
const isCached = isAlbumDownloaded(id, activeServerId);
|
||||
const progress = isDownloading ? getAlbumProgress(id) : null;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={isDownloading}
|
||||
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
|
||||
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,20 +478,13 @@ export default function PlaylistDetail() {
|
||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||
)}
|
||||
{searchResults.map(song => (
|
||||
<div key={song.id} className="playlist-search-row">
|
||||
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||
<div className="playlist-search-info">
|
||||
<span className="playlist-search-title">{song.title}</span>
|
||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||
</div>
|
||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||
<button
|
||||
className="playlist-search-add-btn"
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
onClick={() => addSong(song)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -532,7 +550,13 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
|
||||
{songs.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('playlists.emptyPlaylist')}</div>
|
||||
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<span>{t('playlists.emptyPlaylist')}</span>
|
||||
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
|
||||
<Search size={15} />
|
||||
{t('playlists.addFirstSong')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{songs.map((song, idx) => (
|
||||
@@ -545,16 +569,14 @@ 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' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onMouseEnter={e => handleRowMouseEnter(idx, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||
onDoubleClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
onClick={e => {
|
||||
if (selectedIds.size > 0 && !(e.target as HTMLElement).closest('button, input')) {
|
||||
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 => {
|
||||
@@ -563,7 +585,7 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
{/* # — checkbox in select mode, grip/play on hover otherwise */}
|
||||
{/* # — checkbox in select mode, always-visible play button otherwise */}
|
||||
{(() => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
@@ -572,26 +594,17 @@ export default function PlaylistDetail() {
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (inSelectMode || hoveredSongId === song.id) {
|
||||
toggleSelect(song.id, idx, e.shiftKey);
|
||||
} else {
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[idx], tracks);
|
||||
}
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
|
||||
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: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
|
||||
? <GripVertical size={13} />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: idx + 1}
|
||||
<span 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>
|
||||
);
|
||||
@@ -612,9 +625,9 @@ export default function PlaylistDetail() {
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => handleToggleStar(song, e)}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -696,7 +709,10 @@ export default function PlaylistDetail() {
|
||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onDoubleClick={() => addSong(song)}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addSong(song);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
|
||||
+145
-140
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
@@ -28,6 +28,10 @@ export default function RandomMix() {
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const psyDrag = useDragDrop();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
@@ -105,11 +109,12 @@ export default function RandomMix() {
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
setStarredOverride(song.id, !currentlyStarred);
|
||||
|
||||
try {
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
@@ -117,6 +122,7 @@ export default function RandomMix() {
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
setStarredOverride(song.id, currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,19 +325,28 @@ export default function RandomMix() {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span>{t('randomMix.trackGenre')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
{genreMixSongs.map(song => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = genreMixSongs.map(songToTrack);
|
||||
const isCurrentTrack = currentTrack?.id === song.id;
|
||||
const artist = song.artist;
|
||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const isArtistJustAdded = addedArtist === artist;
|
||||
return (
|
||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -349,21 +364,51 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
|
||||
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{isCurrentTrack && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell">
|
||||
{artist ? (
|
||||
<button
|
||||
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isArtistBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||
setAddedArtist(artist);
|
||||
setTimeout(() => setAddedArtist(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>{artist}</button>
|
||||
) : <span className="track-artist">—</span>}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => toggleSongStar(song, e)}
|
||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedGenre && (loading && songs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -371,26 +416,37 @@ export default function RandomMix() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}>ⓘ</span>
|
||||
</span>
|
||||
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
|
||||
{filteredSongs.map((song) => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = filteredSongs.map(songToTrack);
|
||||
const isCurrentTrack = currentTrack?.id === song.id;
|
||||
const artist = song.artist;
|
||||
const genre = song.genre;
|
||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const isArtistJustAdded = addedArtist === artist;
|
||||
const isGenreBlocked = !!genre && (
|
||||
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
|
||||
);
|
||||
const isGenreJustAdded = addedGenre === genre;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
@@ -413,124 +469,73 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
|
||||
data-tooltip={t('randomMix.play')}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
|
||||
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{isCurrentTrack && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: <Play size={13} fill="currentColor" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-artist-cell">
|
||||
{(() => {
|
||||
const artist = song.artist;
|
||||
if (!artist) return <span className="track-artist">—</span>;
|
||||
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const justAdded = addedArtist === artist;
|
||||
return (
|
||||
<div className="track-artist-cell">
|
||||
{artist ? (
|
||||
<button
|
||||
className="btn btn-ghost track-artist"
|
||||
style={{
|
||||
fontSize: 'inherit',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
|
||||
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
cursor: isBlocked ? 'default' : 'pointer',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 'auto',
|
||||
minHeight: 'unset',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBlocked) return;
|
||||
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
if (!already) {
|
||||
if (isArtistBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||
setAddedArtist(artist);
|
||||
setTimeout(() => setAddedArtist(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>
|
||||
{artist}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>{artist}</button>
|
||||
) : <span className="track-artist">—</span>}
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const genre = song.genre;
|
||||
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</div>;
|
||||
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||
const justAdded = addedGenre === genre;
|
||||
return (
|
||||
<div>
|
||||
{genre ? (
|
||||
<button
|
||||
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isGenreBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||
setAddedGenre(genre);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isGenreBlocked ? t('randomMix.genreBlocked') : isGenreJustAdded ? t('randomMix.genreAddedToBlacklist') : t('randomMix.genreClickHint')}
|
||||
>{genre}</button>
|
||||
) : <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
|
||||
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
|
||||
border: 'none',
|
||||
cursor: isBlocked ? 'default' : 'pointer',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 'auto',
|
||||
minHeight: 'unset',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isBlocked) return;
|
||||
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||
if (!already) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||
setAddedGenre(genre);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => toggleSongStar(song, e)}
|
||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
{genre}
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
||||
</div>
|
||||
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
+118
-1
@@ -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';
|
||||
@@ -54,6 +55,31 @@ const CONTRIBUTORS = [
|
||||
'songToTrack() — unified track construction across all sources',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'nisarg-78',
|
||||
since: '1.29.0',
|
||||
contributions: [
|
||||
'Click-to-seek in synced lyrics (PR #38)',
|
||||
'Volume scroll wheel on volume slider (PR #38)',
|
||||
'Lyrics line visual states: active / completed / upcoming (PR #38)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'JulianNymark',
|
||||
since: '1.29.0',
|
||||
contributions: [
|
||||
'OGG/Vorbis container support via symphonia-format-ogg (PR #42)',
|
||||
'Themed toast notifications for audio playback errors (PR #43)',
|
||||
'Human-readable audio error messages (PR #44)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SPECIAL_THANKS = [
|
||||
{
|
||||
github: 'netherguy4',
|
||||
reason: 'Countless constructive feature ideas and thoughtful feedback',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
@@ -413,6 +439,8 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -562,6 +590,8 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -978,6 +1008,28 @@ export default function Settings() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
@@ -1107,6 +1159,28 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0, paddingTop: 2, fontSize: 13 }}>{t('settings.aboutSpecialThanksLabel')}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', flex: 1 }}>
|
||||
{SPECIAL_THANKS.map(s => (
|
||||
<div key={s.github} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<img
|
||||
src={`https://github.com/${s.github}.png?size=32`}
|
||||
width={22} height={22}
|
||||
style={{ borderRadius: '50%', flexShrink: 0 }}
|
||||
alt={s.github}
|
||||
/>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
|
||||
onClick={() => openUrl(`https://github.com/${s.github}`)}
|
||||
>
|
||||
@{s.github}
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>— {s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -1149,6 +1223,49 @@ function renderInline(text: string): React.ReactNode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function HomeCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const { sections, toggleSection, reset } = useHomeStore();
|
||||
|
||||
const SECTION_LABELS: Record<HomeSectionId, string> = {
|
||||
hero: t('home.hero'),
|
||||
recent: t('home.recent'),
|
||||
discover: t('home.discover'),
|
||||
discoverArtists: t('home.discoverArtists'),
|
||||
recentlyPlayed: t('home.recentlyPlayed'),
|
||||
starred: t('home.starred'),
|
||||
mostPlayed: t('home.mostPlayed'),
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<LayoutGrid size={18} />
|
||||
<h2>{t('settings.homeCustomizerTitle')}</h2>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.sidebarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
{sections.map(sec => (
|
||||
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
|
||||
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
||||
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { onMouseDown } = useDragSource(() => ({
|
||||
|
||||
@@ -33,7 +33,10 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
infiniteQueueEnabled: boolean;
|
||||
showArtistImages: boolean;
|
||||
minimizeToTray: boolean;
|
||||
discordRichPresence: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
@@ -66,7 +69,10 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setDiscordRichPresence: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
@@ -100,7 +106,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
infiniteQueueEnabled: false,
|
||||
showArtistImages: false,
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
nowPlayingEnabled: false,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
@@ -166,7 +175,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
|
||||
|
||||
export interface HomeSectionConfig {
|
||||
id: HomeSectionId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
|
||||
{ id: 'hero', visible: true },
|
||||
{ id: 'recent', visible: true },
|
||||
{ id: 'discover', visible: true },
|
||||
{ id: 'discoverArtists', visible: true },
|
||||
{ id: 'recentlyPlayed', visible: true },
|
||||
{ id: 'starred', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
];
|
||||
|
||||
interface HomeStore {
|
||||
sections: HomeSectionConfig[];
|
||||
toggleSection: (id: HomeSectionId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useHomeStore = create<HomeStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sections: DEFAULT_HOME_SECTIONS,
|
||||
toggleSection: (id) => set((s) => ({
|
||||
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
|
||||
})),
|
||||
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
|
||||
}),
|
||||
{ name: 'psysonic_home' }
|
||||
)
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
@@ -33,6 +33,7 @@ export interface OfflineAlbumMeta {
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
trackIds: string[];
|
||||
type?: 'album' | 'playlist' | 'artist';
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
@@ -49,6 +50,8 @@ interface OfflineState {
|
||||
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
|
||||
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
|
||||
jobs: DownloadJob[];
|
||||
/** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */
|
||||
bulkProgress: Record<string, { done: number; total: number }>;
|
||||
|
||||
isDownloaded: (trackId: string, serverId: string) => boolean;
|
||||
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
|
||||
@@ -62,7 +65,10 @@ interface OfflineState {
|
||||
year: number | undefined,
|
||||
songs: SubsonicSong[],
|
||||
serverId: string,
|
||||
type?: 'album' | 'playlist' | 'artist',
|
||||
) => Promise<void>;
|
||||
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
|
||||
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
|
||||
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
|
||||
clearAll: (serverId: string) => Promise<void>;
|
||||
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
|
||||
@@ -74,6 +80,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
tracks: {},
|
||||
albums: {},
|
||||
jobs: [],
|
||||
bulkProgress: {},
|
||||
|
||||
isDownloaded: (trackId, serverId) =>
|
||||
!!get().tracks[`${serverId}:${trackId}`],
|
||||
@@ -110,7 +117,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
return { done, total: albumJobs.length };
|
||||
},
|
||||
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
|
||||
const CONCURRENCY = 2;
|
||||
const trackIds = songs.map(s => s.id);
|
||||
|
||||
@@ -118,7 +125,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
set(state => ({
|
||||
albums: {
|
||||
...state.albums,
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
|
||||
},
|
||||
jobs: [
|
||||
...state.jobs.filter(j => j.albumId !== albumId),
|
||||
@@ -211,6 +218,42 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
|
||||
// Deduplicate songs (a track can appear multiple times in a playlist).
|
||||
const seen = new Set<string>();
|
||||
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
|
||||
// Store the entire playlist as one virtual album entry so the Offline Library
|
||||
// shows a single card for the playlist rather than one card per album.
|
||||
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
|
||||
},
|
||||
|
||||
downloadArtist: async (artistId, artistName, serverId) => {
|
||||
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
|
||||
try {
|
||||
const res = await getArtist(artistId);
|
||||
albums = res.albums;
|
||||
} catch { return; }
|
||||
set(state => ({
|
||||
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
|
||||
}));
|
||||
for (let i = 0; i < albums.length; i++) {
|
||||
const album = albums[i];
|
||||
try {
|
||||
const { songs } = await getAlbum(album.id);
|
||||
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
|
||||
} catch { /* skip failed album */ }
|
||||
set(state => ({
|
||||
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
|
||||
}));
|
||||
}
|
||||
setTimeout(() => {
|
||||
set(state => {
|
||||
const { [artistId]: _removed, ...rest } = state.bulkProgress;
|
||||
return { bulkProgress: rest };
|
||||
});
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
deleteAlbum: async (albumId, serverId) => {
|
||||
const album = get().albums[`${serverId}:${albumId}`];
|
||||
if (!album) return;
|
||||
|
||||
+213
-17
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
@@ -28,6 +29,8 @@ export interface Track {
|
||||
genre?: string;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
autoAdded?: boolean;
|
||||
radioAdded?: boolean;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -70,12 +73,12 @@ interface PlayerState {
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
togglePlay: () => void;
|
||||
next: () => void;
|
||||
next: (manual?: boolean) => void;
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
@@ -83,6 +86,8 @@ interface PlayerState {
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
enqueueAt: (tracks: Track[], insertIndex: number) => void;
|
||||
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
||||
setRadioArtistId: (artistId: string) => void;
|
||||
clearQueue: () => void;
|
||||
|
||||
isQueueVisible: boolean;
|
||||
@@ -133,6 +138,14 @@ let isAudioPaused = false;
|
||||
// playGeneration has moved on, preventing stale errors from skipping wrong tracks.
|
||||
let playGeneration = 0;
|
||||
|
||||
// Guard against concurrent infinite-queue fetches.
|
||||
let infiniteQueueFetching = false;
|
||||
// Guard against concurrent radio top-up fetches.
|
||||
let radioFetching = false;
|
||||
// Artist ID used to start the current radio session — persists across track
|
||||
// advances so proactive loading works even when songs lack artistId.
|
||||
let currentRadioArtistId: string | null = null;
|
||||
|
||||
// Debounce timer for seek slider drags.
|
||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||
@@ -254,9 +267,9 @@ function handleAudioEnded() {
|
||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue);
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||
} else {
|
||||
usePlayerStore.getState().next();
|
||||
usePlayerStore.getState().next(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
@@ -320,12 +333,16 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
function handleAudioError(message: string) {
|
||||
console.error('[psysonic] Audio error from backend:', message);
|
||||
isAudioPaused = false;
|
||||
|
||||
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
||||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||||
|
||||
const gen = playGeneration;
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
usePlayerStore.getState().next();
|
||||
}, 500);
|
||||
usePlayerStore.getState().next(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,9 +427,50 @@ export function initAudioListeners(): () => void {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Discord Rich Presence sync ────────────────────────────────────────────
|
||||
// Updates on track change or play/pause toggle. No per-tick updates needed —
|
||||
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
|
||||
let discordPrevTrackId: string | null = null;
|
||||
let discordPrevIsPlaying: boolean | null = null;
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
|
||||
const { discordRichPresence } = useAuthStore.getState();
|
||||
|
||||
if (!discordRichPresence || !currentTrack) {
|
||||
if (discordPrevTrackId !== null) {
|
||||
discordPrevTrackId = null;
|
||||
discordPrevIsPlaying = null;
|
||||
invoke('discord_clear_presence').catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trackChanged = currentTrack.id !== discordPrevTrackId;
|
||||
const playingChanged = isPlaying !== discordPrevIsPlaying;
|
||||
if (!trackChanged && !playingChanged) return;
|
||||
|
||||
discordPrevTrackId = currentTrack.id;
|
||||
discordPrevIsPlaying = isPlaying;
|
||||
|
||||
invoke('discord_update_presence', {
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist ?? 'Unknown Artist',
|
||||
album: currentTrack.album ?? null,
|
||||
// Pass elapsed when playing so Discord shows a live running timer.
|
||||
// Pass null when paused — Discord shows the song/artist without a timer.
|
||||
elapsedSecs: isPlaying ? currentTime : null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
|
||||
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubMpris();
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -518,7 +576,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── playTrack ────────────────────────────────────────────────────────────
|
||||
playTrack: (track, queue) => {
|
||||
playTrack: (track, queue, manual = true) => {
|
||||
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
||||
// this playTrack call is likely a stale IPC echo — suppress it.
|
||||
if (Date.now() - lastGaplessSwitchTime < 500) {
|
||||
@@ -559,13 +617,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
manual,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next();
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@@ -624,6 +683,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
manual: false,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
@@ -651,6 +711,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
manual: false,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
@@ -670,17 +731,117 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: () => {
|
||||
const { queue, queueIndex, repeatMode } = get();
|
||||
next: (manual = true) => {
|
||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
get().playTrack(queue[nextIdx], queue, manual);
|
||||
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
||||
// so the queue never runs dry without a visible loading pause.
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching) {
|
||||
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
|
||||
if (remainingAuto <= 2) {
|
||||
infiniteQueueFetching = true;
|
||||
getRandomSongs(5, currentTrack?.genre).then(songs => {
|
||||
if (songs.length > 0) {
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
set(state => ({ queue: [...state.queue, ...newTracks] }));
|
||||
}
|
||||
}).catch(() => {}).finally(() => { infiniteQueueFetching = false; });
|
||||
}
|
||||
}
|
||||
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
|
||||
// of infinite queue setting.
|
||||
const nextTrack = queue[nextIdx];
|
||||
if (nextTrack.radioAdded && !radioFetching) {
|
||||
const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length;
|
||||
if (remainingRadio <= 2) {
|
||||
const artistId = nextTrack.artistId ?? currentRadioArtistId ?? null;
|
||||
const artistName = nextTrack.artist;
|
||||
if (artistId) {
|
||||
radioFetching = true;
|
||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)])
|
||||
.then(([similar, top]) => {
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
const fresh: Track[] = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => !existingIds.has(t.id))
|
||||
.slice(0, 10)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (fresh.length > 0) {
|
||||
set(state => ({ queue: [...state.queue, ...fresh] }));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { radioFetching = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
get().playTrack(queue[0], queue, manual);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
// Queue exhausted. Check radio first (independent of infinite queue setting),
|
||||
// then infinite queue, then stop.
|
||||
if (currentTrack?.radioAdded && !radioFetching) {
|
||||
const artistId = currentTrack.artistId ?? currentRadioArtistId ?? null;
|
||||
if (artistId) {
|
||||
radioFetching = true;
|
||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)])
|
||||
.then(([similar, top]) => {
|
||||
radioFetching = false;
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
const fresh: Track[] = [...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => !existingIds.has(t.id))
|
||||
.slice(0, 10)
|
||||
.map(t => ({ ...t, radioAdded: true as const }));
|
||||
if (fresh.length > 0) {
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...fresh];
|
||||
get().playTrack(fresh[0], newQueue, false);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
radioFetching = false;
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||
if (infiniteQueueFetching) return;
|
||||
infiniteQueueFetching = true;
|
||||
getRandomSongs(5, currentTrack?.genre).then(songs => {
|
||||
infiniteQueueFetching = false;
|
||||
if (songs.length === 0) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...newTracks];
|
||||
get().playTrack(newTracks[0], newQueue, false);
|
||||
}).catch(() => {
|
||||
infiniteQueueFetching = false;
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
});
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -727,7 +888,42 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// ── queue management ─────────────────────────────────────────────────────
|
||||
enqueue: (tracks) => {
|
||||
set(state => {
|
||||
const newQueue = [...state.queue, ...tracks];
|
||||
// Insert before the first upcoming auto-added track so the
|
||||
// "Added automatically" separator always stays at the boundary.
|
||||
const firstAutoIdx = state.queue.findIndex(
|
||||
(t, i) => t.autoAdded && i > state.queueIndex
|
||||
);
|
||||
const newQueue = firstAutoIdx === -1
|
||||
? [...state.queue, ...tracks]
|
||||
: [
|
||||
...state.queue.slice(0, firstAutoIdx),
|
||||
...tracks,
|
||||
...state.queue.slice(firstAutoIdx),
|
||||
];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
|
||||
setRadioArtistId: (artistId) => { currentRadioArtistId = artistId; },
|
||||
|
||||
enqueueRadio: (tracks, artistId) => {
|
||||
if (artistId) currentRadioArtistId = artistId;
|
||||
set(state => {
|
||||
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
|
||||
// again replaces the pending radio batch instead of stacking on top.
|
||||
const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1);
|
||||
const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded);
|
||||
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
|
||||
const firstAutoIdx = upcoming.findIndex(t => t.autoAdded);
|
||||
const merged = firstAutoIdx === -1
|
||||
? [...upcoming, ...tracks]
|
||||
: [
|
||||
...upcoming.slice(0, firstAutoIdx),
|
||||
...tracks,
|
||||
...upcoming.slice(firstAutoIdx),
|
||||
];
|
||||
const newQueue = [...beforeAndCurrent, ...merged];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
|
||||
+134
-1
@@ -723,6 +723,63 @@
|
||||
color: var(--color-error, #e05050);
|
||||
}
|
||||
|
||||
/* Filter tabs */
|
||||
.offline-filter-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.offline-filter-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.offline-filter-tab:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.offline-filter-tab.active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.offline-filter-tab-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.offline-filter-tab.active .offline-filter-tab-count {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Artist discography groups */
|
||||
.offline-artist-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.offline-artist-group-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.album-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1344,6 +1401,10 @@
|
||||
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 {
|
||||
@@ -1492,6 +1553,70 @@
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
/* ── Random Mix — clickable artist name ── */
|
||||
.rm-artist-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: transparent;
|
||||
transition: color 0.15s, text-decoration-color 0.15s;
|
||||
}
|
||||
.rm-artist-btn:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
.rm-artist-btn.is-blocked {
|
||||
color: var(--danger);
|
||||
text-decoration-color: var(--danger);
|
||||
cursor: default;
|
||||
}
|
||||
.rm-artist-btn.just-added {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Random Mix — genre pill chip ── */
|
||||
.rm-genre-chip {
|
||||
display: inline-block;
|
||||
background: var(--bg-hover);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rm-genre-chip:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rm-genre-chip.is-blocked {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
cursor: default;
|
||||
}
|
||||
.rm-genre-chip.just-added {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─ Modal ─ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -1949,7 +2074,7 @@
|
||||
.lyrics-line {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
@@ -1958,10 +2083,18 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lyrics-line.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lyrics-line:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lyrics-plain {
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
|
||||
+124
-1
@@ -243,6 +243,129 @@
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── AppUpdater floating toast ── */
|
||||
.app-updater-toast {
|
||||
position: fixed;
|
||||
bottom: calc(var(--player-height, 80px) + 12px);
|
||||
left: 12px;
|
||||
width: 210px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 9000;
|
||||
animation: update-toast-in 0.35s ease both;
|
||||
}
|
||||
.app-updater-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-updater-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
.app-updater-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-dismiss:hover { opacity: 1; }
|
||||
.app-updater-version {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 19px;
|
||||
}
|
||||
.app-updater-actions {
|
||||
padding-left: 19px;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.app-updater-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.app-updater-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.85;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-btn-primary:hover { opacity: 1; }
|
||||
.app-updater-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.75;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-btn-secondary:hover { opacity: 1; color: var(--text-primary); }
|
||||
.app-updater-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-left: 19px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.app-updater-progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--bg-glass);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-updater-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
.app-updater-pct {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
min-width: 26px;
|
||||
text-align: right;
|
||||
}
|
||||
.app-updater-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 19px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.update-toast {
|
||||
margin: 0 var(--space-1) var(--space-2);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
@@ -594,7 +717,7 @@
|
||||
|
||||
/* Star + Last.fm heart — visually separated from track title */
|
||||
.player-star-btn {
|
||||
margin-left: var(--space-3);
|
||||
margin: var(--space-1);
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
|
||||
+5838
-3838
File diff suppressed because it is too large
Load Diff
+40
-10
@@ -9,16 +9,36 @@ const MAX_CONCURRENT_FETCHES = 5;
|
||||
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
|
||||
// Concurrency limiter for network fetches
|
||||
// Concurrency limiter for network fetches.
|
||||
// Each queue entry is a resolver that signals "slot acquired".
|
||||
let activeFetches = 0;
|
||||
const fetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquireFetchSlot(): Promise<void> {
|
||||
/**
|
||||
* Acquires a fetch slot. Returns true if a slot was granted, false if the
|
||||
* provided AbortSignal fired while the call was waiting in the queue (in that
|
||||
* case no slot is held and the caller must NOT call releaseFetchSlot).
|
||||
*/
|
||||
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
||||
activeFetches++;
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise(resolve => fetchQueue.push(resolve));
|
||||
return new Promise<boolean>(resolve => {
|
||||
const onGrant = () => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
};
|
||||
const onAbort = () => {
|
||||
// Remove from queue without consuming a slot — no releaseFetchSlot needed.
|
||||
const idx = fetchQueue.indexOf(onGrant);
|
||||
if (idx !== -1) fetchQueue.splice(idx, 1);
|
||||
resolve(false);
|
||||
};
|
||||
fetchQueue.push(onGrant);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function releaseFetchSlot(): void {
|
||||
@@ -174,9 +194,11 @@ export async function clearImageCache(): Promise<void> {
|
||||
* Returns a cached object URL for an image.
|
||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||
* @param cacheKey A stable key that identifies the image across sessions.
|
||||
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches
|
||||
* so navigating away does not leave zombie fetches draining I/O.
|
||||
*/
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
|
||||
if (!fetchUrl) return '';
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<string> {
|
||||
if (!fetchUrl || signal?.aborted) return '';
|
||||
|
||||
// 1. In-memory hit (same session)
|
||||
const existing = objectUrlCache.get(cacheKey);
|
||||
@@ -184,6 +206,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
|
||||
// 2. IndexedDB hit (persisted from previous session)
|
||||
const blob = await getBlob(cacheKey);
|
||||
if (signal?.aborted) return '';
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
@@ -191,19 +214,26 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL
|
||||
await acquireFetchSlot();
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL.
|
||||
// acquireFetchSlot returns false (without holding a slot) when aborted in queue.
|
||||
const acquired = await acquireFetchSlot(signal);
|
||||
if (!acquired || signal?.aborted) {
|
||||
if (acquired) releaseFetchSlot();
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
const newBlob = await resp.blob();
|
||||
if (signal?.aborted) return '';
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictMemoryIfNeeded();
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl;
|
||||
} catch (e) {
|
||||
// AbortError → return '' (component is gone). Other errors → return raw URL.
|
||||
return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl;
|
||||
} finally {
|
||||
releaseFetchSlot();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Lightweight DOM-based toast notification.
|
||||
* Uses the app's CSS custom properties so it respects the active theme.
|
||||
* Multiple toasts stack vertically above each other.
|
||||
*/
|
||||
|
||||
const TOAST_GAP = 8;
|
||||
const TOAST_BOTTOM_ANCHOR = 100;
|
||||
|
||||
function getActiveToasts(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.psysonic-toast'));
|
||||
}
|
||||
|
||||
function reflow(): void {
|
||||
const toasts = getActiveToasts();
|
||||
let bottom = TOAST_BOTTOM_ANCHOR;
|
||||
for (let i = toasts.length - 1; i >= 0; i--) {
|
||||
toasts[i].style.bottom = `${bottom}px`;
|
||||
bottom += toasts[i].offsetHeight + TOAST_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToastVariant = 'error' | 'info';
|
||||
|
||||
export function showToast(text: string, durationMs = 4000, variant: ToastVariant = 'info'): void {
|
||||
const isError = variant === 'error';
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'psysonic-toast';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.textContent = isError ? '✕' : 'ℹ';
|
||||
icon.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${isError ? 'var(--danger)' : 'var(--accent)'};
|
||||
color: var(--bg-app);
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = text;
|
||||
msg.style.cssText = `flex: 1; min-width: 0;`;
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: ${TOAST_BOTTOM_ANCHOR}px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid ${isError ? 'var(--danger)' : 'var(--border)'};
|
||||
border-left: 3px solid ${isError ? 'var(--danger)' : 'var(--accent)'};
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
z-index: 999999;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45)${isError ? ', 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)' : ''};
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
transition: bottom 150ms ease;
|
||||
max-width: 480px;
|
||||
width: max-content;
|
||||
`;
|
||||
|
||||
toast.appendChild(icon);
|
||||
toast.appendChild(msg);
|
||||
document.body.appendChild(toast);
|
||||
reflow();
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
reflow();
|
||||
}, durationMs);
|
||||
}
|
||||
Reference in New Issue
Block a user