Compare commits

..

109 Commits

Author SHA1 Message Date
Psychotoxical d49af977ed docs: add v1.34.1 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:12:48 +02:00
Psychotoxical d73f348339 feat: v1.34.1 — fullscreen lyrics overlay, FsArt crossfade, lyrics toggle, contributor updates
- FullscreenPlayer: synced lyrics overlay (5-line rail, mask fade, click-to-seek)
- FullscreenPlayer: FsArt crossfade component with onLoad pattern, 300px art, next-track pre-fetch
- FullscreenPlayer: MicVocal lyrics toggle button next to heart (persisted in authStore)
- FullscreenPlayer: idle system — only close button auto-hides, cluster+seekbar always visible
- LyricsPane: refactored to use shared useLyrics hook (no double-fetch with FS overlay)
- authStore: showFullscreenLyrics field (default true)
- i18n: fsLyricsToggle key added to all 7 locales; ru2.ts removed (merged into ru.ts)
- Settings: Contributors updated (nisrael, cucadmuh, kilyabin); logout moved to Server tab as btn-danger
- theme.css: btn-danger style added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:00:33 +02:00
Psychotoxical 390e6e788d chore: bump version to 1.34.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:59:32 +02:00
Psychotoxical ff950efb0c feat(settings): add nisrael to contributors, update cucadmuh/kilyabin, move logout button
- Add nisrael (Nightfox themes PR #114, rustls-tls fix PR #112)
- Update cucadmuh with gapless manual skip fix (PR #119)
- Update kilyabin with RU locale improvements (PR #120) and auto-install script (PR #121)
- Move logout button from System tab to Server tab — styled as btn-danger (outlined red)
- Add .btn-danger CSS class to theme.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:54:27 +02:00
Psychotoxical 4ef21d6d78 i18n(ru): apply translation improvements from PR #120
Manually merged kilyabin's Russian locale improvements (more natural phrasing
throughout) while preserving keys added after #120 was opened: fsLyricsToggle,
lyricsServerFirst/Desc, lyricsSourceServer/Lrclib.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:46:41 +02:00
kilyabin 7fc0550b65 feat: auto-install script for Debian/RHEL 2026-04-06 22:39:19 +02:00
cucadmuh 5d06738ce1 fix(audio): gapless manual skip — honor user-initiated play over pre-chained track
* fix(audio): honor manual skip when next track is gapless-pre-chained

* fix(audio): match gapless chain and preload by stream id, not full URL
2026-04-06 22:39:16 +02:00
Psychotoxical 1197c1f916 feat: album cover art in Discord Rich Presence via iTunes Search API
Fetches album artwork from the iTunes Search API and passes the URL
directly to Discord's large_image field. Subsonic cover URLs require
auth and can't be used by Discord directly.

- 3-strategy search: exact quoted → relaxed → track title fallback
- 1-hour in-memory cache per artist|album key
- iTunes search runs in tokio::task::spawn_blocking (reqwest::blocking
  must not run on the Tokio async executor)
- artwork_cache wrapped in Arc<Mutex<...>> for cross-thread sharing
- Fallback to the pre-uploaded "psysonic" asset when no match found
- Cargo.toml: blocking feature alongside rustls-tls

Co-Authored-By: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:30:22 +02:00
Psychotoxical b448c2bc82 fix(queue): revert auto-scroll target back to queueIndex+1
The queue-current-track section already shows the active track above the
list. Scrolling to queueIndex+1 (next track) is the correct behavior —
it keeps the upcoming tracks in view, not the already-visible current one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:23:55 +02:00
cucadmuh 6226383762 i18n(ru): refine Russian locale using phrasing from ru2
Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-06 19:13:47 +02:00
nisarg 13a8f696d0 fix: queue auto-scroll to current track and fix re-renders
* stop queue panel from constant rerendering

* add queueHeader type

* fix(queue): move hook before early return, fix auto-scroll target

- usePlayerStore must be called before conditional return (Rules of Hooks)
- Auto-scroll to songs[queueIndex] not [queueIndex+1] — current track,
  not the next one

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:11:03 +02:00
Nils Israel 64e0948904 feat: integrate nightfox.nvim themes into Open Source Classics
* feat: integrate nightfox.nvim themes into Open Source Classics

Agent-Logs-Url: https://github.com/nisrael/psysonic/sessions/410b9047-62de-4c0b-a6bc-1dc6f8247164

Co-authored-by: nisrael <66925+nisrael@users.noreply.github.com>

* i18n: add Nightfox to theme FAQ in de + zh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nisrael <66925+nisrael@users.noreply.github.com>
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:09:39 +02:00
Nils Israel 10b2bde5ef fix: switch reqwest to rustls-tls for cross-platform TLS compatibility
The native-tls backend (macOS Security framework) was failing with
'bad protocol version' when connecting to HTTPS music servers.
Switching to rustls (statically linked, no system dependency) fixes
playback on macOS and ensures consistent TLS 1.2/1.3 support across
all platforms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 19:06:55 +02:00
Psychotoxical 1c92f9ed74 docs: add Discord community link to README 2026-04-06 13:23:57 +02:00
Psychotoxical 443e0c0675 fix(ci): remove redundant tauri-action release params to prevent duplicate drafts 2026-04-06 13:18:03 +02:00
Psychotoxical 9cc8cfe087 fix(ci): force bash shell on Windows for gh release step 2026-04-06 13:00:43 +02:00
Psychotoxical 7ac417fbfc chore: bump AUR PKGBUILD to 1.34.0 2026-04-06 12:51:08 +02:00
Psychotoxical 50ac1b8284 feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix
Co-authored-by: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:46:13 +02:00
Psychotoxical 29203803de feat(i18n): Russian locale and translations in src/locales
feat(i18n): Russian locale and translations in src/locales
2026-04-06 10:42:45 +02:00
Maxim Isaev 83c36de091 feat(i18n): Russian locale and translations in src/locales
- Add Russian UI strings with idiomatic phrasing and plural forms
- Split en/de/fr/nl/zh/nb strings into src/locales/*.ts for maintainability
- Register ru in i18n and Settings language picker
- Add languageRu to all locales; extend English help (supported languages)

Made-with: Cursor
2026-04-06 11:08:40 +03:00
Psychotoxical 1d0965708b Update README.md 2026-04-06 02:07:23 +02:00
Psychotoxical 2b5416f423 Update README.md 2026-04-06 02:07:04 +02:00
Psychotoxical 8cd4cdcd64 feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:00:48 +02:00
Psychotoxical a32ca64792 Merge pull request #101: Added Norwegian language
Added Norwegian language
2026-04-06 01:42:56 +02:00
zz55zz 939abace35 Added Norwegian language
Added Norwegian language
2026-04-05 13:15:22 -08:00
Psychotoxical 4f7236e986 Update CHANGELOG.md 2026-04-05 20:07:17 +02:00
Psychotoxical 9be0d8dfa9 feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal,
cover upload), Backup/Restore, Albums year filter, Statistics Library
Insights (playtime/genres/formats), Playlist cover upload, resizable
tracklist columns for Playlists & Favorites, crossfade fine control,
Settings Storage tab redesign, various fixes and UI polish.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:34:26 +02:00
Psychotoxical 67f31b0700 fix: include InternetRadio.tsx (referenced in App.tsx router) 2026-04-04 02:09:19 +02:00
Psychotoxical c873880a26 feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:04:14 +02:00
Psychotoxical 463b7483fd fix: wrap next/previous in arrow functions to satisfy MouseEventHandler type 2026-04-03 15:00:36 +02:00
Psychotoxical 3d11ef91a1 chore: bump AUR pkgver to 1.30.0 2026-04-03 14:54:59 +02:00
Psychotoxical c365140870 feat: v1.30.0 — Discord RPC, offline bulk download, artist images, lazy loading, crossfade fix
- Discord Rich Presence (opt-in) — requested by @Bewenben (#49)
- Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54)
- Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping
- Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53)
- Image lazy loading via IntersectionObserver (300px margin) across all pages
- Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35)
- Fix: playlist offline cache now stored as single entry (not per-album)
- Fix: image cache AbortController no longer blocks IDB writes
- Update toast: experimental auto-update hint + GH download link always visible (Win/Mac)
- Queue tech strip: genre removed
- Facebook theme: contrast, opaque badge/back button, queue tab labels
- "Save discography offline" label (was "Download discography")
- Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround)
- starredOverrides propagated to AlbumDetail, Favorites, RandomMix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:53:41 +02:00
Psychotoxical 651b3cb050 Update README.md 2026-04-03 12:02:34 +02:00
Psychotoxical e2ee9247ad chore: attribute contributions from JulianNymark and nisarg-78
JulianNymark contributed OGG/Vorbis support, audio error toasts, and
human-readable error messages (PRs #42, #43, #44). nisarg-78 contributed
QoL and UI improvements (PR #38). Changes were incorporated manually
due to conflicts at the time of merge.

Co-Authored-By: JulianNymark <819074+JulianNymark@users.noreply.github.com>
Co-Authored-By: nisarg-78 <84626554+nisarg-78@users.noreply.github.com>
2026-04-02 22:45:58 +02:00
Psychotoxical 74df7b6b88 Update CHANGELOG.md 2026-04-02 22:36:36 +02:00
Psychotoxical 27a6693c8c Update CHANGELOG.md 2026-04-02 22:28:35 +02:00
Psychotoxical a932e7c2db chore: bump to v1.29.0 — release prep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:24:41 +02:00
Psychotoxical 7263d93d42 feat: v1.29.0 — Radio fast-start, seek fix, OGG, error toasts, contributors
Incorporates PRs #38 (nisarg-78), #42 #43 #44 (JulianNymark) with fixes:

Audio / Playback
- Artist Radio starts immediately from fast getTopSongs (local library);
  getSimilarSongs2 (Last.fm) enriches queue in background — no more wait
- Fix seek audio glitch: EqualPowerFadeIn only resets to zero-gain on
  seeks to track start (<100 ms); all other seeks resume at unity gain
- OGG/Vorbis container support via symphonia-format-ogg (PR #42)
- Human-readable audio error messages in SizedDecoder (PR #44)
- Audio playback errors shown as themed toast notifications (PR #43)

Queue / Radio
- Infinite Queue: proactive load of 5 tracks (was 25) when ≤2 remain
- Radio: proactive reload at ≤2 remaining tracks, independent of
  Infinite Queue setting — radio no longer stops at last track
- Fix: clicking Start Radio multiple times no longer stacks duplicates
- Fix: Start Radio on artist keeps current song playing
- Manual tracks always appear before Radio, Radio before Auto-added
- Queue separators: "— Radio —" and "— Auto —" dividers
- Fix: radio proactive load now works even when songs lack artistId
  (uses currentRadioArtistId module var as fallback)

UI / UX
- Click synced lyrics lines to seek (PR #38)
- Volume scroll wheel on volume slider (PR #38)
- Lyrics: active / completed / upcoming visual states (PR #38)
- Shared toast utility (src/utils/toast.ts) replaces inline toast fn
- Auto-updater: relaunch_after_update Rust command exits first to
  release single-instance lock before spawning new process

About / Credits
- nisarg-78 and JulianNymark added to contributors (since v1.29.0)
- netherguy4 added as Special Thanks for feature ideas and feedback
- i18n: aboutSpecialThanksLabel in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:17:55 +02:00
Psychotoxical 95283d792b feat: v1.28.0 — Infinite Queue, Start Radio, Single-click Play, Performance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:40:39 +02:00
Psychotoxical 53d5888ebf chore: bump AUR pkgrel to 1.27.4-2 (CFLAGS fix rollout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:25:31 +02:00
Psychotoxical cad4338324 fix(aur): unset CFLAGS/CXXFLAGS to fix ring LTO link failure on CachyOS
CachyOS makepkg.conf sets -flto=auto in CFLAGS. ring's build.rs uses the
cc crate to compile its C/asm objects, which picks up CFLAGS, producing
fat-LTO objects. bfd cannot resolve ring_core_* symbols from fat-LTO
objects when linking against non-LTO Rust rlibs.

Also adds nasm to makedepends (required by ring 0.17.x for x86_64 asm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:41:34 +02:00
Psychotoxical f9bc67cb77 fix(aur): append -fuse-ld=bfd to RUSTFLAGS — substitution was no-op on CachyOS 2026-04-02 11:54:50 +02:00
Psychotoxical 74c75d83ca fix(aur): override RUSTFLAGS to swap lld→bfd for ring linker error
makepkg.conf on Arch injects -fuse-ld=lld into RUSTFLAGS which overrides
.cargo/config.toml target rustflags. PKGBUILD now explicitly patches
RUSTFLAGS at env level so bfd is used regardless of system config.

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

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

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

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

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

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

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

### Changed
- buildStreamUrl: removed unused suffix parameter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:57:57 +02:00
Psychotoxical 7d1c66071e Update CHANGELOG.md 2026-04-01 17:28:50 +02:00
Psychotoxical 1adfda1daa fix: remove invalid single-instance capability + dead static tray icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:51:50 +02:00
Psychotoxical e65c476a76 fix: v1.25.1 — Opus playback, single-instance enforcement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:45:37 +02:00
Psychotoxical 560349819f feat: v1.25.0 — Tray Icon, Minimize to Tray, Sidebar Customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:46:41 +02:00
Psychotoxical ada5327493 chore: bump AUR pkgver to 1.24.0 2026-03-31 23:14:45 +02:00
Psychotoxical c67d606f89 feat: v1.24.0 — Playlist Management, native sample rate playback
- Full playlist feature: overview grid, detail page with hero collage,
  tracklist DnD, song search, suggestions, context menu submenu
- Audio: disable all app-level resampling — every track plays at its
  native sample rate (target_rate always 0 in audio_play + chain_next)
- Fix: playlist hero bg flicker (memoize buildCoverArtUrl calls)
- Fix: input focus double-border (search-input → .input class)
- Polish: redesigned playlist search panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:14:15 +02:00
Psychotoxical 662cc94ca8 fix: disable forced resampling to 44100 Hz on first track
current_sample_rate was initialized to 44100, causing every track to be
resampled down to 44.1 kHz. Setting it to 0 disables resampling until
the actual track rate is known, so songs play at their native sample rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:01:58 +02:00
Psychotoxical 1eacaf678c Update README.md 2026-03-30 23:16:47 +02:00
Psychotoxical 4a8fb64c66 Update README.md 2026-03-30 23:16:04 +02:00
Psychotoxical 43c656dfc3 feat: v1.23.0 — Advanced Search, Genre Mix overhaul, Playlist append, Contributors table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:56:59 +02:00
Psychotoxical 74b519f9f5 feat: Replay Gain support — songToTrack() + audio_update_replay_gain
Integrates PR #9 by @trbn1: replay gain was not applying because track
objects were built manually without replayGainTrackDb/replayGainAlbumDb/
replayGainPeak fields. All track construction sites now use songToTrack().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:46 +02:00
Psychotoxical 3d03b8d5a1 merge: PR #9 — Replay Gain fix (trbn1) + conflict resolution
Merges songToTrack() helper and audio_update_replay_gain Tauri command
from @trbn1 without losing v1.22.0 DnD/queue-management work:

- All track construction sites use songToTrack() for correct replay gain
- psy-drag system (mouse-event DnD) preserved across all components
- enqueueAt() position-aware insertion preserved in QueuePanel
- updatePlaylist / smart-save / active-playlist tracking preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:07:29 +02:00
Psychotoxical d6f6e6466c feat: v1.22.0 — Queue Management, DnD Overhaul, Seek & Waveform Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:14:59 +02:00
trbn 95cdbc7fc7 fix: replay gain not applying to tracks
Replay gain was not working because track objects were created manually without
including replay gain metadata from the Subsonic API response.

Changes:
- Add songToTrack() helper function to properly map SubsonicSong to Track with
  replayGainTrackDb, replayGainAlbumDb, and replayGainPeak fields
- Add audio_update_replay_gain Tauri command for dynamic volume recalculation
  when replay gain settings change mid-playback
- Add updateReplayGainForCurrentTrack() to recalculate volume when toggling
  replay gain setting
- Fetch fresh track data on cold resume (app relaunch) to ensure replay gain
  values are current from server
- Update all files that create track objects to use songToTrack()

Fixes issue where toggling replay gain ON/OFF or changing between track/album
mode had no effect on currently playing or newly played tracks.
2026-03-30 19:11:33 +02:00
Psychotoxical 42863877f6 fix: close seek-flash race window after debounce expires
PR #7 blocked stale Rust progress ticks during the 100 ms debounce
window but lifted the guard exactly when invoke('audio_seek') went out.
The engine can still emit 1–2 ticks with the old position before the
seek takes effect.  Add seekTarget: once the debounce fires, record the
requested time and keep ignoring progress until current_time is within
2 s of that target, then clear it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:28:48 +02:00
Psychotoxical 7ed0fa4914 Merge branch 'pr-8' into test/pr-7-8 2026-03-30 17:24:18 +02:00
Joshua Bassett 4f8e7d7bc7 fix: stabilize waveform seekbar width when player time changes
Add min-width to .player-time elements so they don't resize when the
displayed time string changes length (e.g. "9:59" → "10:00"). Not all
fonts support the tabular-nums font-variant, so without a min-width the
time elements can change size and cause the waveform seekbar to
continuously resize as playback progresses.

Also align the elapsed time to the right and duration to the left so
they sit flush against the waveform edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:17:50 +11:00
Joshua Bassett bb56269cd1 fix: prevent waveform seekbar flashing back to old position on seek
When clicking/dragging the waveform seekbar, the playback position would
briefly flash back to the previous position before snapping to the new
one.

The seek function optimistically updates the store with the target
position immediately but debounces the actual audio_seek IPC call by
100ms. During that window, audio:progress events from the Rust backend
continue to arrive carrying the old playback position, overwriting the
optimistic update and causing the visual flash.

The fix skips incoming audio:progress events while a seek debounce is
pending, since the store already holds the correct target position. Once
the debounce fires and audio_seek is sent to the backend, progress
events resume normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:55 +11:00
Psychotoxical 29a4363dca feat: v1.21.0 — What's New Modal, 3 New Themes (Beta), Artist Column, Powerslave Blue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:48:40 +02:00
Psychotoxical e1d27798eb feat: v1.20.0 — FLAC Seek Fix, Genres, Genre Filter, Chinese, W10 Theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:04:01 +02:00
Psychotoxical b35539d3cf Merge pull request #3 from jiezhuo/feature/chinese_translations
Add Chinese language
2026-03-29 16:32:46 +02:00
jiezhuo a1b3022140 Add Chinese language 2026-03-28 22:04:13 +08:00
Psychotoxical b6fb66c46d feat: v1.19.0 — NSIS Installer, Tray Removed, Storage Warning, Theme Polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:56:35 +01:00
Psychotoxical 936e548f40 feat: v1.18.0 — Offline Mode (Beta), MPRIS Seek, 2 New Themes, Perf Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:17:09 +01:00
Psychotoxical b67c198227 fix: player bar CSS + Windows media keys HWND (v1.17.2)
- layout: overflow:hidden on .app-shell + min-height:0 on sidebar/main/queue
  prevents player bar from being pushed off-screen when window is resized
  below OS minHeight constraint (ignored by some Linux WMs)
- lib.rs: pass main window HWND to souvlaki PlatformConfig on Windows so
  SMTC hooks into the existing Win32 message loop instead of creating its
  own — fixes media keys on Windows without crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:15:25 +01:00
Psychotoxical 65a828e3fa fix: disable souvlaki on Windows to fix startup crash (v1.17.1)
SMTC init via souvlaki requires a valid HWND + COM message loop, which
are not available in setup(). Guarded behind #[cfg(not(target_os = "windows"))].
mpris_set_metadata / mpris_set_playback no-op on Windows via the None branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:02:09 +01:00
Psychotoxical 6bdd6f3a59 feat: v1.17.0 — Media Keys, 3 New Themes, Perf Fixes, Contrast Audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:30:13 +01:00
Psychotoxical d62bffd082 feat: v1.16.0 — 15 New Themes, W98 Overhaul, Aqua Quartz Polish
New themes: Aqua Quartz (Mac OS X Aqua, skeuomorphic), Spider-Tech,
T-800, B-Runner, Hill Valley 85, TetraStack, Turtle Power, Insta,
ReadIt, The Book (new Social Media group), W3.1, Jayfin (Jellyfin).

W98 rebuilt from scratch: authentic #d4d0c8 warm-gray, full 4-layer
3D bevel on all panels/buttons (raised/sunken on press), title bar
gradient on song name, navy progress fill, 16px styled scrollbar.

Aqua Quartz: all button variants now jelly-styled, blue Source List
sidebar with white pill nav, aluminium pinstripe background.

Theme Picker: groups and themes sorted alphabetically, Mediaplayer
group renamed, Pandora/Order of the Phoenix/Imperial Sith removed.

Fix: AlbumDetail genre propagation, W98 accordion active state,
Aqua Quartz sidebar labels, W98 connection indicator contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:54:57 +01:00
Psychotoxical ff706104ab fix: propagate genre in AlbumDetail track constructions
Play All, Enqueue All, and single-song play in AlbumDetail were
building Track objects without genre/starred fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:57:17 +01:00
Psychotoxical 0abef4b266 docs: update README screenshot 2026-03-23 19:52:18 +01:00
Psychotoxical 3effad0830 feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:47:32 +01:00
Psychotoxical 361e9cfdb3 feat: v1.14.0 — Critical Buffer Fix, Gapless/Crossfade stable, UX polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:00:55 +01:00
Psychotoxical 5516d95b52 feat: v1.13.0 — SVG Logo, Marquee, Player UX, Global Shortcuts fix
### Added
- SVG logo with theme-adaptive gradient in sidebar (full wordmark + P-icon for collapsed state)
- Player bar: song title/artist marquee scroll on overflow
- Player bar: live volume percentage tooltip on slider hover

### Changed
- Sidebar collapse button moved to right-edge hover tab
- Player bar: fixed 320px track info width, increased waveform margins
- Settings: Server tab opens by default
- Crossfade: experimental badge removed (stable)
- Help page: Lyrics/Keybindings/Font entries added, theme count corrected

### Fixed
- Global shortcuts double-fire (Rust-side ShortcutMap idempotency fix)
- W98 theme: comprehensive contrast fixes for navy hover backgrounds
- Help page: removed orphaned translation key under Playback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:24:14 +01:00
Psychotoxical d927ef2082 feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:10:06 +01:00
Psychotoxical 867c5fbd3e feat: v1.11.0 — Movies themes, Settings polish, Gapless stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:37:49 +01:00
Psychotoxical e550340565 feat: v1.10.0 — new streaming themes, favourite toggle fix, home page improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:58:05 +01:00
Psychotoxical 57b70e6154 feat: v1.9.0 — new themes, keybindings, font picker, home play behavior
Themes:
- Add Neon Drift (midnight blue / electric cyan synthwave)
- Add Cupertino Light + Cupertino Dark (macOS Ventura-inspired, frosted glass)
- Add Betriebssysteme group (Cupertino, Aero Glass, Luna Teal)
- Rename all trademarked theme IDs to original names (WnAmp, Navy Jukebox,
  Cobalt Media, Onyx Cinema, Aero Glass, Luna Teal)
- Reorder ThemePicker: Psysonic Themes first, then Mediaplayer, Betriebssysteme

Keybindings:
- New keybindingsStore with 10 bindable actions (play/pause, next, prev,
  volume, seek ±10s, queue, fullscreen, native fullscreen)
- Settings UI for rebinding; defaults: Space=play-pause, F11=native-fullscreen

Font picker:
- New fontStore; 10 UI fonts selectable in Settings → Appearance
- Applied via data-font attribute on <html>

Home page:
- AlbumCard: Details button → Play button via playAlbum() utility
- Hero: Play Album starts playback with 700ms fade-out instead of navigating
- playAlbum.ts: fade, store-only volume restore, playTrack handoff

Now Playing:
- 3-column hero layout; EQ bars truly centred (flex:1 on both outer columns)
- Background brightness 0.25→0.55, overlay 0.55→0.38 (art now visible)
- Better text contrast for track times, card links, section titles

Linux audio:
- Set PIPEWIRE_LATENCY + PULSE_LATENCY_MSEC before stream creation
  to reduce ALSA snd_pcm_recover underrun frequency on PipeWire

Remove butterchurn visualizer (VisualizerCanvas, butterchurn.d.ts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:22:02 +01:00
Psychotoxical 0b1ed8cc5a feat: v1.8.0 — new themes, queue toolbar, lightbox, i18n (fr/nl)
Themes:
- Add Poison (phosphor green on charcoal), Nucleo (warm brass light), Classic Winamp (LCD glow, Winamp yellow/orange) themes
- Overhaul Psychowave — refined deep violet, no longer WIP
- Reorganise ThemePicker into groups: Catppuccin, Nord, Retro, Tokyo Night, Psysonic Themes
- Add --volume-accent CSS var for per-theme volume slider colour override

Queue:
- Full toolbar redesign with centred round buttons (Shuffle/Save/Load/Clear/Gapless/Crossfade)
- Crossfade popover with 1–10 s range slider, right-aligned to avoid viewport overflow
- Queue header: title + count + duration inline in accent colour, close button removed
- Tech info (codec/bitrate) as frosted-glass overlay badge on cover art

UI:
- CoverLightbox shared component for album cover (AlbumHeader) and artist avatar (ArtistDetail)
- NowPlayingDropdown: separate spinning/loading state — button always clickable, icon spins 600 ms min
- Settings: "Experimental" badge on Crossfade and Gapless toggles
- Help page: 2-column grid layout, new Crossfade & Gapless Q&A entry, updated theme/language entries

i18n:
- Full French (fr) and Dutch (nl) translations across all namespaces
- Language selector sorted alphabetically (nl, en, fr, de)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:05:16 +01:00
Psychotoxical c9c68a0e57 docs: add Roadmap section, update features list 2026-03-20 19:32:57 +01:00
Psychotoxical 9d4997baac fix: Last.fm auth stability improvements (v1.7.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:14:04 +01:00
Psychotoxical a99e2e0657 docs: update CLAUDE.md to v1.7.1 2026-03-20 19:07:06 +01:00
Psychotoxical 7f85b587b4 fix: TypeScript build errors breaking release build (v1.7.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:56:15 +01:00
Psychotoxical c8d5e9c028 fix: TypeScript build errors in Settings and Statistics (count→n, relativeTime t type)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:53:12 +01:00
Psychotoxical 2ba7845c79 feat: Last.fm beta, Similar Artists, Statistics Last.fm stats, TooltipPortal, CustomSelect, Psychowave theme (v1.7.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:35:26 +01:00
Psychotoxical 9400a5fb2b docs: warn about settings reset in v1.6.0 changelog (identifier change)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:48:26 +01:00
Psychotoxical 0e88e8a5cd feat: Replay Gain, Crossfade, Download Folder Modal, Changelog in Settings (v1.6.0)
### Audio
- Replay Gain support (track + album mode, configurable pre-gain, hard limiter)
- Crossfade between tracks (configurable duration 1–10 s)
- Gapless preloading ⚠️ experimental/alpha — enable in Settings → Playback
- Atomic sink swap: old track plays until new one is decoded, then stops cleanly

### UI / UX
- Settings redesigned with tab navigation (General, Playback, About)
- Changelog viewer in Settings → About with collapsible version entries
- Download Folder Modal: choose folder + "remember" checkbox per-download
- EQ popup now accessible directly from the Player Bar
- "Also Featured On" section on Artist pages for non-album appearances

### Fixes
- Bundle identifier changed from dev.psysonic.app → dev.psysonic.player (fixes macOS warning)
- Version sync: all four version files (package.json, Cargo.toml, tauri.conf.json, PKGBUILD) now at 1.6.0

### Known Issues
- FLAC seeking via waveform seekbar does not work (MP3/OGG unaffected) — under investigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:23:17 +01:00
Psychotoxical 7de4b97df0 chore: bump AUR PKGBUILD to v1.5.0, fix tag URL format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:26:28 +01:00
Psychotoxical 59115a09d2 feat: 10-band EQ, connection indicator, new icon (v1.5.0)
### 10-Band Graphic Equalizer
- Full EQ in Rust audio engine via EqSource<S> biquad peak filters
- 10 built-in presets + custom preset save/delete
- EqSource::try_seek() implemented — also fixes waveform seek which broke
  silently when EQ was introduced (rodio returned SeekError::NotSupported)
- EQ state persisted in localStorage, synced to Rust on startup

### Connection Indicator
- LED in header (green/red/pulsing) with server name and LAN/WAN label
- Offline overlay with retry button when server is unreachable
- useConnectionStatus hook

### New App Icon
- New logo (logo-psysonic.png) applied across Login, Sidebar, Settings,
  README and all Tauri platform icons (Windows, macOS, Linux, Android, iOS)

### Now Playing Page
- New /now-playing route added

### Fixes
- WaveformSeek: mousemove/mouseup on window to fix drag outside canvas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:25:32 +01:00
Psychotoxical 18199a1f8a feat: queue improvements, system browser links (v1.4.5)
- Queue: year added to now-playing meta, cover enlarged to 90px + top-aligned, default width 340px
- Artist pages: Last.fm/Wikipedia open in system browser with button label feedback
- README: AUR badge + install section, Nord theme mention, in-app browser bullet removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:19:07 +01:00
Psychotoxical d4e44199e9 docs: update CLAUDE.md for v1.4.4 2026-03-17 19:16:29 +01:00
Psychotoxical 9b4eb0982c feat: new app icon, AUR package, remove AppImage (v1.4.4)
- New app icon across all platforms
- AUR package: Arch/CachyOS users can install via yay/paru
- AppImage removed: incompatible with non-Ubuntu distros due to
  bundled WebKitGTK vs system Mesa/EGL conflicts
- CI: build only deb+rpm for Linux, drop GStreamer/linuxdeploy deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:10:08 +01:00
Psychotoxical 8ec642f368 fix: Random Mix improvements, queue UX fixes (v1.4.3)
- Random Mix: remove redundant Play All button in genre mix section
- Random Mix: Play All disabled with live counter during genre mix loading
- Random Mix: cap genre fetch at 50 genres to prevent over-fetching
- Random Mix: cache-bust getRandomSongs to always get fresh results
- Random Mix: clear songs on remix to fix display/play mismatch
- Queue: show song count and total duration in header
- Queue: context-active class keeps hover highlight on right-click
- Context Menu: add Favorite option for queue-item type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:38:27 +01:00
Psychotoxical 73f836b2ee fix: AppImage EGL crash on modern distros, update link capability (v1.4.2)
- Switch Linux CI build from ubuntu-22.04 to ubuntu-24.04 — bundles
  WebKitGTK 2.44 which uses eglGetPlatformDisplay instead of the
  deprecated eglGetDisplay(EGL_DEFAULT_DISPLAY) that fails on Mesa 25.x
  (CachyOS/Arch with RDNA 4 and other modern hardware)
- Add EGL_PLATFORM=x11 to AppRun as additional safeguard for XWayland
- Fix shell:allow-open capability missing URL scope (update toast link
  was silently blocked by Tauri v2 without explicit allow-list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:26:33 +01:00
184 changed files with 48033 additions and 4939 deletions
+12
View File
@@ -0,0 +1,12 @@
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
# bfd is always available via binutils (part of base-devel on Arch).
#
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
# overrides target-specific rustflags here. The PKGBUILD's build() applies
# the same fix by patching the RUSTFLAGS env var directly.
[target.x86_64-unknown-linux-gnu]
linker = "cc"
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
+132 -28
View File
@@ -19,23 +19,48 @@ 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
- name: extract changelog
id: changelog
run: |
VERSION="${{ steps.get-version.outputs.version }}"
# Extract the block between ## [VERSION] and the next ## heading
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
# Store multiline output
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
- name: create release
id: create-release
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
with:
script: |
const tag = `app-v${process.env.PACKAGE_VERSION}`;
const body = process.env.CHANGELOG_BODY || 'See the assets to download this version and install.';
try {
const { data } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: data.id,
body,
});
return data.id;
} catch (e) {
if (e.status !== 404) throw e;
@@ -45,8 +70,8 @@ jobs:
repo: context.repo.repo,
tag_name: tag,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body: 'See the assets to download this version and install.',
draft: false,
body,
draft: true,
prerelease: false
});
return data.id;
@@ -55,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:
@@ -64,7 +92,7 @@ jobs:
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: ''
args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
@@ -72,24 +100,78 @@ 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
# tauri-action auto-generates a latest.json using the pubkey from tauri.conf.json
# as the signature value (wrong). Delete it so only our generate-update-manifest.js
# output is ever used as the canonical update manifest.
- name: remove tauri-action generated update manifest
shell: bash
run: |
gh release delete-asset ${{ github.ref_name }} latest.json --yes || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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:
contents: write
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
@@ -98,43 +180,36 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-libav
- name: install linuxdeploy gstreamer plugin
run: |
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
libasound2-dev
- 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: 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
- name: build
run: npm run tauri:build
env:
APPIMAGE_EXTRACT_AND_RUN: 1
- name: patch AppImage AppRun
run: |
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
echo "Patching: $APPIMAGE"
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
-O appimagetool
chmod +x appimagetool
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
rm -rf squashfs-root appimagetool
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
run: npm run tauri:build -- --bundles deb,rpm
- name: upload Linux artifacts
env:
@@ -142,5 +217,34 @@ jobs:
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
\( -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
+11
View File
@@ -7,6 +7,11 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment variables (API keys)
.env
.env.local
.env.*.local
# Node
node_modules
dist
@@ -29,3 +34,9 @@ src-tauri/target/
# Documentation
CLAUDE.md
# Claude Code memory (local only)
memory/
# Local scratchpad / notes (not committed)
tmp/
+1056
View File
File diff suppressed because it is too large Load Diff
+97 -14
View File
@@ -1,18 +1,29 @@
<div align="center">
<img src="public/logo.png" alt="Psysonic Logo" width="200"/>
<h1>Psysonic</h1>
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="300"/>
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
<p>
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
---
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) aesthetic.
<div align="center">
<a href="https://discord.gg/ckVPGPMS">
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
</a>
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
</div>
---
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
@@ -21,33 +32,105 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ Features
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
- 🌍 **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 Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
- 🎵 **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, 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.
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
- 🔤 **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.
- 🔗 **In-App Browser**: Last.fm and Wikipedia links on artist pages open in a dedicated native window — no need to leave the app.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
- 🏷️ **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.
- 🔄 **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).
## ● Known Limitations
## 🗺️ Roadmap
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
### ✅ Completed
- [x] Native Rust/rodio audio engine (replaces Howler.js)
- [x] 10-band graphic EQ with built-in and custom presets
- [x] Crossfade between tracks
- [x] Replay Gain (track + album mode)
- [x] Gapless playback
- [x] Waveform seekbar
- [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, 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)
- [x] Advanced Search (text + genre + year + result-type filters)
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
- [x] Internationalization (English, German, French, Dutch, Chinese)
- [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
### 📋 Planned
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
---
## 📥 Installation
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` or `.msi`
### 🐧 Linux
**Quick Install (Recommended):**
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
**Manual Installation:**
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux**: `.AppImage` or `.deb`
> [!WARNING]
> **Gatekeeper Note:**
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
> ```sh
> xattr -cr /Applications/Psysonic.app
> ```
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 📦 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
+58 -69
View File
@@ -1,23 +1,24 @@
{
"name": "psysonic",
"version": "1.3.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.3.0",
"version": "1.30.0",
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@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-notification": "^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",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -1226,6 +1227,33 @@
"win32"
]
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
@@ -1495,10 +1523,10 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"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"
@@ -1522,6 +1550,24 @@
"@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",
"integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1657,16 +1703,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"license": "MIT",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -1714,27 +1750,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/butterchurn": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"ecma-proposal-math-extensions": "0.0.2"
}
},
"node_modules/butterchurn-presets": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
"license": "MIT",
"dependencies": {
"babel-runtime": "^6.26.0",
"ecma-proposal-math-extensions": "0.0.2",
"lodash": "^4.17.4"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1797,14 +1812,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
@@ -1862,12 +1869,6 @@
"node": ">= 0.4"
}
},
"node_modules/ecma-proposal-math-extensions": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
@@ -2226,12 +2227,6 @@
"node": ">=6"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2345,9 +2340,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2486,12 +2481,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.4.1",
"version": "1.34.1",
"private": true,
"scripts": {
"dev": "vite",
@@ -11,16 +11,17 @@
"tauri:build": "tauri build"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@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-notification": "^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",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
+4
View File
@@ -0,0 +1,4 @@
src/
pkg/
*.tar.zst
*.tar.gz
+80
View File
@@ -0,0 +1,80 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
url="https://github.com/Psychotoxical/psysonic"
license=('GPL-3.0-only')
depends=(
'webkit2gtk-4.1'
'gtk3'
'openssl'
'alsa-lib'
)
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')
build() {
cd "psysonic-$pkgver"
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
}
package() {
cd "psysonic-$pkgver"
# Binary (in /usr/lib to make room for the wrapper)
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
#!/bin/sh
export GDK_BACKEND=x11
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec /usr/lib/psysonic/psysonic "\$@"
EOF
# Desktop entry
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
[Desktop Entry]
Name=Psysonic
Comment=Desktop music player for Subsonic API-compatible servers
Exec=psysonic
Icon=psysonic
Terminal=false
Type=Application
Categories=AudioVideo;Audio;Music;Player;
EOF
# Icons
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 82 KiB

+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="115.549mm"
height="130.30972mm"
viewBox="0 0 115.549 130.30972"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:export-filename="p-small.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"><inkscape:page
x="0"
y="0"
width="115.549"
height="130.30972"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
id="defs1" /><g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(220.53237,27.789086)"><path
style="fill:#ffffff"
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
id="path1" /></g></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+52
View File
@@ -0,0 +1,52 @@
# Scripts
## install.sh - Auto-Installer for Debian and RHEL-based Systems
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
### Supported Distributions
- **Debian/Ubuntu**: Downloads and installs `.deb` package
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
### Usage
#### Quick Install (Recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
#### Manual Installation
```bash
# Download the script
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
# Make it executable
chmod +x install.sh
# Run with sudo
sudo ./install.sh
```
### What it does
1. Detects your OS type (Debian or RHEL-based)
2. Fetches the latest release from GitHub
3. Downloads the appropriate package (.deb or .rpm)
4. Installs it using your system's package manager
5. Cleans up temporary files
### Requirements
- `curl` - for downloading packages
- `sudo` or root access
- Internet connection
- Supported package manager (apt-get, dnf, or yum)
### Notes
- If Psysonic is already installed, the script will ask if you want to reinstall
- The script automatically handles dependency installation for Debian systems
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
+90
View File
@@ -0,0 +1,90 @@
#!/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 = {};
// A real minisign .sig file is multi-line and ~200+ chars.
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
function validateSignature(sig, platform, sigFile) {
// Public keys start with RWT and are a single short base64 token
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
throw new Error(
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
` Got: ${sig}\n` +
` The TAURI_SIGNING_PUBLIC_KEY env var must never be used as the signature value.\n` +
` Ensure the signing step correctly writes the .sig file from the private key.`
);
}
if (sig.length < 80) {
throw new Error(
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
);
}
}
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();
validateSignature(signature, platform, sigFile);
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
}
}
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(', ')}`);
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
set -e
# Psysonic Auto-Installer
# Automatically detects your OS and installs the latest release from GitHub
REPO="Psychotoxical/psysonic"
APP_NAME="psysonic"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
exit 1
}
# Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then
error "Please run this script as root (use sudo)"
fi
}
# Detect package manager and OS type
detect_os() {
if command -v apt &> /dev/null; then
OS_TYPE="debian"
PACKAGE_MANAGER="apt"
info "Detected Debian/Ubuntu-based system (apt)"
elif command -v dnf &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="dnf"
info "Detected RHEL/Fedora-based system (dnf)"
elif command -v yum &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="yum"
info "Detected RHEL/CentOS-based system (yum)"
else
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
fi
}
# Get the latest release download URL for the specific package type
get_download_url() {
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
info "Fetching latest release information..."
local release_info
release_info=$(curl -s "$api_url")
if echo "$release_info" | grep -q "message.*Not Found"; then
error "Could not fetch release information. Please check your internet connection."
fi
local tag_name
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$tag_name" ]; then
error "Could not determine latest release version."
fi
info "Latest version: $tag_name"
local download_url=""
if [ "$OS_TYPE" = "debian" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
elif [ "$OS_TYPE" = "rhel" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
fi
if [ -z "$download_url" ]; then
error "Could not find download URL for $OS_TYPE package."
fi
echo "$download_url"
}
# Install the package
install_package() {
local download_url="$1"
local temp_dir
temp_dir=$(mktemp -d)
local package_file="$temp_dir/${APP_NAME}_latest"
info "Downloading package..."
if [ "$OS_TYPE" = "debian" ]; then
package_file="${package_file}.deb"
curl -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file" || {
warn "Trying to fix broken dependencies..."
$PACKAGE_MANAGER install -f -y
}
elif [ "$OS_TYPE" = "rhel" ]; then
package_file="${package_file}.rpm"
curl -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file"
fi
# Cleanup
rm -rf "$temp_dir"
}
# Check if app is already installed
check_installed() {
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
warn "${APP_NAME} appears to be already installed."
read -p "Do you want to reinstall? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Installation cancelled."
exit 0
fi
fi
}
# Main installation flow
main() {
echo -e "${GREEN}"
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ Psysonic Auto-Installer ║"
echo "║ Install the latest release from GitHub Releases ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
check_root
detect_os
check_installed
local download_url
download_url=$(get_download_url)
if [ -z "$download_url" ]; then
error "Failed to get download URL."
fi
info "Download URL: $download_url"
install_package "$download_url"
echo ""
success "Psysonic has been installed successfully!"
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
echo ""
}
# Run main function
main
+1052 -265
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.4.1"
version = "1.34.1"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -22,8 +22,8 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
@@ -31,5 +31,16 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
reqwest = { version = "0.12", features = ["stream"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
ringbuf = "0.3"
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"
url = "2"
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
would restrict network access more than without it. macOS users bypass
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
Respected by ad-hoc signatures on some macOS versions. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
<key>com.apple.security.device.audio-input</key>
<false/>
</dict>
</plist>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music player only — it does not record audio.
This description is shown if macOS prompts for microphone access
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
Navidrome servers running without HTTPS can load media in WKWebView.
Without this, macOS App Transport Security blocks HTTP audio src. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+9 -3
View File
@@ -7,8 +7,7 @@
"permissions": [
"core:default",
"shell:default",
"shell:allow-open",
"notification:default",
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:default",
@@ -18,10 +17,15 @@
"store:allow-save",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-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",
@@ -29,6 +33,8 @@
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-create",
"core:webview:allow-create-webview-window"
"core:webview:allow-create-webview-window",
"updater:default",
"process:allow-restart"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
{"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","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-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"]}}
+111 -183
View File
@@ -6015,202 +6015,34 @@
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"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": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
"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 batch command without any pre-configured scope.",
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
"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`",
@@ -6451,6 +6283,102 @@
"type": "string",
"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",
"const": "window-state:default",
"markdownDescription": "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`"
},
{
"description": "Enables the filename command without any pre-configured scope.",
"type": "string",
"const": "window-state:allow-filename",
"markdownDescription": "Enables the filename command without any pre-configured scope."
},
{
"description": "Enables the restore_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:allow-restore-state",
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
},
{
"description": "Enables the save_window_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:allow-save-window-state",
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
},
{
"description": "Denies the filename command without any pre-configured scope.",
"type": "string",
"const": "window-state:deny-filename",
"markdownDescription": "Denies the filename command without any pre-configured scope."
},
{
"description": "Denies the restore_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:deny-restore-state",
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
},
{
"description": "Denies the save_window_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:deny-save-window-state",
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
}
]
},
+111 -183
View File
@@ -6015,202 +6015,34 @@
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"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": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
"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 batch command without any pre-configured scope.",
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
"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`",
@@ -6451,6 +6283,102 @@
"type": "string",
"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",
"const": "window-state:default",
"markdownDescription": "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`"
},
{
"description": "Enables the filename command without any pre-configured scope.",
"type": "string",
"const": "window-state:allow-filename",
"markdownDescription": "Enables the filename command without any pre-configured scope."
},
{
"description": "Enables the restore_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:allow-restore-state",
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
},
{
"description": "Enables the save_window_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:allow-save-window-state",
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
},
{
"description": "Denies the filename command without any pre-configured scope.",
"type": "string",
"const": "window-state:deny-filename",
"markdownDescription": "Denies the filename command without any pre-configured scope."
},
{
"description": "Denies the restore_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:deny-restore-state",
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
},
{
"description": "Denies the save_window_state command without any pre-configured scope.",
"type": "string",
"const": "window-state:deny-save-window-state",
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
}
]
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+2366 -137
View File
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
/// Discord Rich Presence integration.
///
/// Album artwork is fetched from the iTunes Search API and passed directly to
/// Discord via the large_image URL field. This avoids the need to pre-upload
/// assets to the Discord Developer Portal.
///
/// 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, ActivityType, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use reqwest::blocking::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
const DISCORD_APP_ID: &str = "1489544859718258779";
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
pub struct ArtworkCacheEntry {
pub url: String,
pub fetched_at: Instant,
}
/// TTL: 1 hour — album artwork doesn't change, but we don't want to cache failures forever.
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
pub struct DiscordState {
pub client: Mutex<Option<DiscordIpcClient>>,
/// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking.
pub artwork_cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>>,
/// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally).
pub http_client: Client,
}
impl DiscordState {
pub fn new() -> Self {
DiscordState {
client: Mutex::new(None),
artwork_cache: Arc::new(Mutex::new(HashMap::new())),
http_client: Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
}
// ─── iTunes Search API ───────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct ItunesResponse {
results: Vec<ItunesResult>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct ItunesResult {
collectionName: Option<String>,
artistName: Option<String>,
artworkUrl100: Option<String>,
}
/// Normalize string for comparison: lowercase, trim, collapse whitespace.
fn normalize(s: &str) -> String {
s.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Search for album artwork via iTunes Search API.
/// Returns a higher-resolution URL (600x600) if found.
///
/// Takes explicit `client` and `cache` so this can be called from inside
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
fn search_itunes_artwork(
client: &Client,
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
artist: &str,
album: &str,
title: &str,
) -> Option<String> {
let cache_key = format!("{}|{}", artist, album);
// Check cache first
{
let c = cache.lock().ok()?;
if let Some(entry) = c.get(&cache_key) {
if entry.fetched_at.elapsed() < ARTWORK_CACHE_TTL {
return Some(entry.url.clone());
}
}
}
let norm_artist = normalize(artist);
let norm_album = normalize(album);
let norm_title = normalize(title);
// Strategy 1: exact match search — "artist" "album"
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
.append_pair("media", "music")
.append_pair("entity", "album")
.append_pair("limit", "5");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
// Strategy 2: relaxed search — artist album (no quotes)
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, album))
.append_pair("media", "music")
.append_pair("entity", "album")
.append_pair("limit", "10");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
// Strategy 3: search by track title — artist + title (for singles/rare albums)
if !title.is_empty() {
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, title))
.append_pair("media", "music")
.append_pair("entity", "song")
.append_pair("limit", "10");
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_title) {
cache_and_return(cache, cache_key, &result);
return Some(result);
}
}
None
}
fn search_with_url(
client: &Client,
url: url::Url,
norm_artist: &str,
norm_album: &str,
) -> Option<String> {
let resp = client.get(url).send().ok()?;
let body: ItunesResponse = resp.json().ok()?;
for result in &body.results {
let collection = normalize(result.collectionName.as_deref().unwrap_or(""));
let result_artist = normalize(result.artistName.as_deref().unwrap_or(""));
// Flexible matching: check if strings contain each other
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
let artist_match = norm_artist == result_artist
|| norm_artist.contains(&result_artist)
|| result_artist.contains(&norm_artist)
|| words_overlap(norm_artist, &result_artist);
let album_match = norm_album == collection
|| norm_album.contains(&collection)
|| collection.contains(norm_album)
|| words_overlap(norm_album, &collection);
if artist_match && album_match {
return Some(result.artworkUrl100.as_ref()?.replace("100x100", "600x600"));
}
}
None
}
/// Check if two strings share at least 50% of their words.
fn words_overlap(a: &str, b: &str) -> bool {
let words_a: std::collections::HashSet<_> = a.split_whitespace().collect();
let words_b: std::collections::HashSet<_> = b.split_whitespace().collect();
if words_a.is_empty() || words_b.is_empty() {
return false;
}
let common = words_a.intersection(&words_b).count();
let min_len = words_a.len().min(words_b.len());
common >= min_len / 2 + min_len % 2 // At least 50% overlap
}
fn cache_and_return(
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
key: String,
url: &str,
) {
if let Ok(mut c) = cache.lock() {
c.insert(
key,
ArtworkCacheEntry {
url: url.to_string(),
fetched_at: Instant::now(),
},
);
}
}
/// 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.
/// - `cover_art_url`: optional direct URL to album artwork. If None, tries to
/// fetch from iTunes Search API using artist + album.
#[tauri::command]
pub async fn discord_update_presence(
state: tauri::State<'_, DiscordState>,
title: String,
artist: String,
album: Option<String>,
elapsed_secs: Option<f64>,
cover_art_url: Option<String>,
) -> Result<(), String> {
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
// run on the Tokio async executor directly.
let artwork_url: Option<String> = if let Some(url) = cover_art_url {
Some(url)
} else if let Some(ref album_name) = album {
let http_client = state.http_client.clone();
let cache = Arc::clone(&state.artwork_cache);
let artist_c = artist.clone();
let album_c = album_name.clone();
let title_c = title.clone();
tokio::task::spawn_blocking(move || {
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
})
.await
.ok()
.flatten()
} else {
None
};
let mut guard = state.client.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();
// Discord RPC only exposes two visible text rows (details + state).
// The application name "Psysonic" is shown automatically by Discord as the
// header line. Album goes into large_text — visible as a hover tooltip on
// the cover art icon.
let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = if let Some(ref url) = artwork_url {
Assets::new()
.large_image(url.as_str())
.large_text(large_text)
} else {
// Fallback to default Psysonic icon
Assets::new()
.large_image("psysonic")
.large_text(large_text)
};
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&title)
.state(&artist)
.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.client.lock().unwrap();
if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() {
*guard = None;
}
}
Ok(())
}
+817 -70
View File
@@ -2,13 +2,30 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
type TrayState = Mutex<Option<TrayIcon>>;
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
@@ -19,108 +36,838 @@ 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);
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
#[tauri::command]
async fn upload_playlist_cover(
server_url: String,
playlist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_artist_image(
server_url: String,
artist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn delete_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
}
const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
#[tauri::command]
async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/search")
.header("User-Agent", "psysonic/1.0")
.query(&[
("name", query.as_str()),
("hidebroken", "true"),
("limit", limit_s.as_str()),
("offset", offset_s.as_str()),
])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
#[tauri::command]
async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/topvote")
.header("User-Agent", "psysonic/1.0")
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("User-Agent", "psysonic/1.0")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.split(';')
.next()
.unwrap_or("image/jpeg")
.trim()
.to_string();
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
Ok((bytes.to_vec(), content_type))
}
/// 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.
#[tauri::command]
async fn lastfm_request(
params: Vec<[String; 2]>,
sign: bool,
get: bool,
api_key: String,
api_secret: String,
) -> Result<serde_json::Value, String> {
use std::collections::HashMap;
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
map.insert("api_key".into(), api_key.clone());
if sign {
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort();
let sig_str: String = keys.iter()
.filter(|k| k.as_str() != "format" && k.as_str() != "callback")
.map(|k| format!("{}{}", k, map[k]))
.collect::<String>();
let sig_input = format!("{}{}", sig_str, api_secret);
let digest = md5::compute(sig_input.as_bytes());
map.insert("api_sig".into(), format!("{:x}", digest));
}
map.insert("format".into(), "json".into());
let client = reqwest::Client::new();
let resp = if get {
client
.get("https://ws.audioscrobbler.com/2.0/")
.query(&map)
.header("User-Agent", "psysonic/1.13.0")
.send()
.await
} else {
client
.post("https://ws.audioscrobbler.com/2.0/")
.form(&map)
.header("User-Agent", "psysonic/1.13.0")
.send()
.await
}.map_err(|e| e.to_string())?;
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(err) = json.get("error") {
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
}
Ok(json)
}
#[tauri::command]
fn register_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
shortcut: String,
action: String,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let mut map = shortcut_map.lock().unwrap();
// Idempotent: if this exact shortcut+action is already registered, skip.
// This prevents on_shortcut() from accumulating duplicate handlers when
// registerAll() is called again after a JS HMR reload or StrictMode double-effect.
if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) {
return Ok(());
}
// Unregister any existing OS grab for this shortcut before re-registering.
if let Ok(s) = shortcut.parse::<Shortcut>() {
let _ = app.global_shortcut().unregister(s);
}
map.insert(shortcut.clone(), action.clone());
drop(map); // release lock before the blocking OS call
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
app.global_shortcut()
.on_shortcut(parsed, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match action.as_str() {
"play-pause" => "media:play-pause",
"next" => "media:next",
"prev" => "media:prev",
"volume-up" => "media:volume-up",
"volume-down" => "media:volume-down",
_ => return,
};
let _ = app.emit(event_name, ());
}
})
.map_err(|e| e.to_string())
}
#[tauri::command]
fn unregister_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
shortcut: String,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
shortcut_map.lock().unwrap().remove(&shortcut);
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
app.global_shortcut().unregister(parsed).map_err(|e| e.to_string())
}
#[tauri::command]
fn mpris_set_metadata(
controls: tauri::State<MprisControls>,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
cover_url: Option<String>,
duration_secs: Option<f64>,
) -> Result<(), String> {
use souvlaki::MediaMetadata;
use std::time::Duration;
let duration = duration_secs.map(|s| Duration::from_secs_f64(s));
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
album: album.as_deref(),
cover_url: cover_url.as_deref(),
duration,
})
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
#[tauri::command]
fn mpris_set_playback(
controls: tauri::State<MprisControls>,
playing: bool,
position_secs: Option<f64>,
) -> Result<(), String> {
use souvlaki::{MediaPlayback, MediaPosition};
use std::time::Duration;
let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s)));
let playback = if playing {
MediaPlayback::Playing { progress }
} else {
MediaPlayback::Paused { progress }
};
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
ctrl.set_playback(playback)
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
}
// ─── Offline Track Cache ──────────────────────────────────────────────────────
/// Downloads a single track to the app's offline cache directory.
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
#[tauri::command]
async fn download_track_offline(
track_id: String,
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<String, String> {
// Determine base cache directory.
let cache_dir = if let Some(ref cd) = custom_dir {
let base = std::path::PathBuf::from(cd);
// Check that the volume/directory is still accessible.
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
base.join(&server_id)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
};
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
// Already cached — skip re-download.
if file_path.exists() {
return Ok(path_str);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
Ok(path_str)
}
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() { return 0; }
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
let default_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
Err(_) => return 0,
};
let mut total = dir_size(default_dir);
if let Some(cd) = custom_dir {
let custom = std::path::PathBuf::from(cd);
if custom != std::path::PathBuf::from("") {
total += dir_size(custom);
}
}
total
}
/// Removes a cached track from the offline cache. Accepts the full local path
/// (stored in OfflineTrackMeta) so it works regardless of which directory was used.
/// After deleting the file, empty parent directories up to (but not including)
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
#[tauri::command]
async fn delete_offline_track(
local_path: String,
base_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
// Determine the safe boundary — never delete at or above this directory.
let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) {
std::path::PathBuf::from(bd)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
};
// Walk upward, pruning directories that have become empty.
// Stops as soon as a non-empty directory or the boundary is reached.
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break; // Directory still has contents — stop pruning.
}
if std::fs::remove_dir(&dir).is_err() {
break; // Could not remove (e.g. permissions) — stop.
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
/// Builds and returns a new system-tray icon with all menu items and event handlers.
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { stop_audio_engine(app); app.exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)
}
/// Show (`true`) or fully remove (`false`) the system-tray icon.
///
/// The command is strictly idempotent:
/// - `show=true` when the icon is already present → no-op (prevents duplicate icons).
/// - `show=false` when the icon is already absent → no-op.
///
/// For removal, `set_visible(false)` is called explicitly before the handle is
/// dropped because some platforms (Windows notification area, certain Linux DEs)
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
show: bool,
) -> Result<(), String> {
let mut guard = tray_state.lock().unwrap();
if show {
// Early-return when already shown — never build a second icon.
if guard.is_some() {
return Ok(());
}
*guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?);
} else if let Some(tray) = guard.take() {
// Hide synchronously before dropping so the OS processes the removal
// before any subsequent show=true call can create a new icon.
let _ = tray.set_visible(false);
// `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem).
}
Ok(())
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
fn stop_audio_engine(app: &tauri::AppHandle) {
let engine = app.state::<audio::AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
tauri::Builder::default()
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(TrayState::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
let window = app.get_webview_window("main").expect("no main window");
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}))
.setup(|app| {
// Build tray menu
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let separator = tauri::menu::PredefinedMenuItem::separator(app)?;
let show = MenuItemBuilder::with_id("show", "Show Psysonic").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit").build(app)?;
// ── System tray ───────────────────────────────────────────────
// Always build on startup; the frontend calls toggle_tray_icon(false)
// immediately after load if the user has disabled the tray icon.
{
let tray = build_tray_icon(app.handle())?;
*app.state::<TrayState>().lock().unwrap() = Some(tray);
}
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&next)
.item(&separator)
.item(&show)
.item(&quit)
.build()?;
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
{
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => {
let _ = app.emit("tray:play-pause", ());
}
"next" => {
let _ = app.emit("tray:next", ());
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
// Collect pre-conditions and the platform-specific HWND.
// Returns None early (with a log) on any unrecoverable condition
// so app.manage() always executes exactly once at the bottom.
let maybe_controls: Option<MediaControls> = (|| {
// Linux: requires a live D-Bus session.
#[cfg(target_os = "linux")]
{
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
.map(|v| !v.is_empty())
.unwrap_or(false);
if !dbus_ok {
eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
return None;
}
}
"quit" => {
std::process::exit(0);
}
_ => {}
})
.on_tray_icon_event(|_tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
// Left click shows app (handled in JS side via tray event)
}
})
.build(app)?;
// Register media key global shortcuts
#[cfg(not(target_os = "linux"))]
{
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
for shortcut_str in &shortcuts {
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
let shortcut_clone = shortcut_str.to_string();
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match shortcut_clone.as_str() {
"MediaPlayPause" => "media:play-pause",
"MediaNextTrack" => "media:next",
"MediaPreviousTrack" => "media:prev",
_ => return,
};
let _ = app.emit(event_name, ());
// Windows: souvlaki SMTC must hook into the existing Win32
// message loop rather than spinning up its own. Pass the
// main window's HWND so it can do so. If we can't get one,
// skip init (no crash, just no media overlay).
#[cfg(target_os = "windows")]
let hwnd = {
use tauri::Manager;
let h = app.get_webview_window("main")
.and_then(|w| w.hwnd().ok())
.map(|h| h.0 as *mut std::ffi::c_void);
if h.is_none() {
eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled");
return None;
}
h
};
#[cfg(not(target_os = "windows"))]
let hwnd: Option<*mut std::ffi::c_void> = None;
let config = PlatformConfig {
dbus_name: "psysonic",
display_name: "Psysonic",
hwnd,
};
match MediaControls::new(config) {
Ok(mut controls) => {
let app_handle = app.handle().clone();
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
match event {
MediaControlEvent::Toggle
| MediaControlEvent::Play
| MediaControlEvent::Pause => {
let _ = app_handle.emit("media:play-pause", ());
}
MediaControlEvent::Next => {
let _ = app_handle.emit("media:next", ());
}
MediaControlEvent::Previous => {
let _ = app_handle.emit("media:prev", ());
}
MediaControlEvent::Seek(direction) => {
use souvlaki::SeekDirection;
let delta: f64 = match direction {
SeekDirection::Forward => 5.0,
SeekDirection::Backward => -5.0,
};
let _ = app_handle.emit("media:seek-relative", delta);
}
MediaControlEvent::SetPosition(pos) => {
let secs = pos.0.as_secs_f64();
let _ = app_handle.emit("media:seek-absolute", secs);
}
_ => {}
}
}) {
eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
}
});
Some(controls)
}
Err(e) => {
eprintln!("[Psysonic] Could not create media controls: {e:?}");
None
}
}
}
})();
app.manage(MprisControls::new(maybe_controls));
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
// Emit event so JS can decide: hide to tray or allow close.
// JS handles prevent_close via onCloseRequested() in App.tsx.
let _ = window.emit("window:close-requested", ());
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
api.prevent_close();
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Stop the audio engine first so sound cuts immediately.
let app = window.app_handle();
stop_audio_engine(app);
app.exit(0);
}
#[cfg(not(target_os = "macos"))]
{
// Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ());
}
}
}
})
.invoke_handler(tauri::generate_handler![
greet,
exit_app,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
mpris_set_playback,
audio::audio_play,
audio::audio_pause,
audio::audio_resume,
audio::audio_stop,
audio::audio_seek,
audio::audio_set_volume,
audio::audio_update_replay_gain,
audio::audio_set_eq,
audio::autoeq_entries,
audio::autoeq_fetch_profile,
audio::audio_preload,
audio::audio_play_radio,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
discord::discord_update_presence,
discord::discord_clear_presence,
lastfm_request,
upload_playlist_cover,
upload_radio_cover,
upload_artist_image,
delete_radio_cover,
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
relaunch_after_update,
toggle_tray_icon,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+20 -13
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.4.1",
"identifier": "dev.psysonic.app",
"version": "1.34.1",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
@@ -16,24 +16,27 @@
"title": "Psysonic",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"minWidth": 360,
"minHeight": 480,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false,
"visible": true,
"dragDropEnabled": false
"dragDropEnabled": false,
"devtools": false
}
],
"security": {
"csp": null
},
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": false,
"title": "Psysonic",
"tooltip": "Psysonic"
}
},
"plugins": {
"updater": {
"pubkey": "RWTgsDNd9LqppH6DMq7ypolI0bsLCNsjOvif4mrHyr4UDidkUT69IY1K",
"endpoints": [
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
]
}
},
"bundle": {
@@ -54,9 +57,13 @@
"bundleMediaFramework": true
}
},
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15"
},
"windows": {
"wix": {
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
"nsis": {
"installMode": "currentUser"
}
}
}
+334 -57
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
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';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -7,6 +8,9 @@ import { PanelRight, PanelRightClose } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Sidebar from './components/Sidebar';
import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
@@ -22,15 +26,40 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
import Statistics from './pages/Statistics';
import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal';
import DownloadFolderModal from './components/DownloadFolderModal';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import OfflineBanner from './components/OfflineBanner';
import OfflineLibrary from './pages/OfflineLibrary';
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';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore';
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoggedIn, servers, activeServerId } = useAuthStore();
@@ -40,6 +69,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
function AppShell() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
@@ -47,11 +77,36 @@ function AppShell() {
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
const navigate = useNavigate();
const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus);
useEffect(() => {
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
navigate('/offline', { replace: true });
}
// Return from offline page only when reconnecting (not when user navigates there manually while online)
if (connStatus === 'connected' && prev === 'disconnected' && location.pathname === '/offline') {
navigate('/', { replace: true });
}
}, [connStatus, hasOfflineContent, location.pathname, navigate]);
useEffect(() => {
initializeFromServerQueue();
}, [initializeFromServerQueue]);
useEffect(() => {
useEqStore.getState().syncToRust();
}, []);
useEffect(() => {
const fn = async () => {
try {
@@ -70,10 +125,19 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
useEffect(() => {
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
setChangelogModalOpen(true);
}
}, []);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
});
const [queueWidth, setQueueWidth] = useState(300);
const [queueWidth, setQueueWidth] = useState(340);
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
useEffect(() => {
@@ -82,7 +146,7 @@ function AppShell() {
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingQueue) {
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
setQueueWidth(newWidth);
}
}, [isDraggingQueue]);
@@ -110,33 +174,101 @@ function AppShell() {
};
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
// ── Global DnD fix for Linux/WebKitGTK ──────────────────────────
// WebKitGTK (used by Tauri on Linux) requires the document itself to
// accept drags via preventDefault() on dragover/dragenter. Without
// this, the webview shows a "forbidden" cursor for all in-app HTML5
// drag-and-drop because it never sees a valid drop target at the
// document level. This is harmless on Windows/macOS where DnD already
// works correctly.
useEffect(() => {
const allow = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
// Prevent the webview from navigating when something (e.g. a file
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
// Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts
const blockSelectAll = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const target = e.target as HTMLElement;
// Allow Ctrl+A inside actual text inputs and textareas
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
}
};
// Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection
const blockSelectStart = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if ((target as HTMLElement).closest('[data-selectable]')) return;
e.preventDefault();
};
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
document.addEventListener('keydown', blockSelectAll, true);
document.addEventListener('selectstart', blockSelectStart);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
document.removeEventListener('keydown', blockSelectAll, true);
document.removeEventListener('selectstart', blockSelectStart);
};
}, []);
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
className="app-shell"
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
)}
<main className="main-content">
<header className="content-header">
<LiveSearch />
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<NowPlayingDropdown />
<button
className="collapse-btn"
className="queue-toggle-btn"
onClick={toggleQueue}
title={t('player.toggleQueue')}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
<div className="content-body" style={{ padding: 0 }}>
{connStatus === 'disconnected' && hasOfflineContent && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
)}
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
{connStatus === 'disconnected' && !hasOfflineContent && (
<OfflineOverlay
serverName={serverName}
onRetry={connRetry}
isChecking={connRetrying}
/>
)}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
@@ -147,97 +279,239 @@ function AppShell() {
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random-mix" element={<RandomMix />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
<Route path="/genres" element={<Genres />} />
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
</Routes>
</div>
</main>
<div
className="resizer resizer-queue"
onMouseDown={(e) => {
e.preventDefault();
setIsDraggingQueue(true);
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
/>
<QueuePanel />
<PlayerBar />
{!isMobile && (
<div
className="resizer resizer-queue"
onMouseDown={(e) => {
e.preventDefault();
setIsDraggingQueue(true);
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
/>
)}
{!isMobile && <QueuePanel />}
{isMobile && !isMobilePlayer && <BottomNav />}
{!isMobilePlayer && <PlayerBar />}
{isFullscreenOpen && (
<FullscreenPlayer onClose={toggleFullscreen} />
)}
<ContextMenu />
<SongInfoModal />
<DownloadFolderModal />
<TooltipPortal />
<AppUpdater />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div>
);
}
// Tray / media key event handler
// Media key + tray event handler
function TauriEventBridge() {
const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const { minimizeToTray } = useAuthStore();
// Spacebar → play/pause, F11 → window fullscreen
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.code === 'F11') {
e.preventDefault();
const win = getCurrentWindow();
win.isFullscreen().then(fs => win.setFullscreen(!fs));
return;
}
if (e.code !== 'Space') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Global shortcuts use modifier combos — skip in-app bindings for those
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
if (e.ctrlKey || e.altKey || e.metaKey) return;
const { bindings } = useKeybindingsStore.getState();
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
const action = (Object.entries(bindings) as [string, string | null][])
.find(([, code]) => code === e.code)?.[0];
if (!action) return;
e.preventDefault();
togglePlay();
switch (action) {
case 'play-pause': togglePlay(); break;
case 'next': next(); break;
case 'prev': previous(); break;
case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break;
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
case 'seek-forward': {
const s = usePlayerStore.getState();
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
break;
}
case 'seek-backward': {
const s = usePlayerStore.getState();
seek(Math.max(0, s.currentTime - 10));
break;
}
case 'toggle-queue': toggleQueue(); break;
case 'fullscreen-player': toggleFullscreen(); break;
case 'native-fullscreen': {
const win = getCurrentWindow();
win.isFullscreen().then(fs => win.setFullscreen(!fs));
break;
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [togglePlay]);
}, []);
useEffect(() => {
let cancelled = false;
const unlisten: Array<() => void> = [];
listen('media:play-pause', () => togglePlay()).then(u => unlisten.push(u));
listen('media:next', () => next()).then(u => unlisten.push(u));
listen('media:prev', () => previous()).then(u => unlisten.push(u));
listen('tray:play-pause', () => togglePlay()).then(u => unlisten.push(u));
listen('tray:next', () => next()).then(u => unlisten.push(u));
// Handle close → minimize to tray if enabled (Tauri 2 approach)
const win = getCurrentWindow();
win.onCloseRequested(async (event) => {
if (minimizeToTray) {
event.preventDefault();
await win.hide();
} else {
// If not minimizing to tray, we want to exit the app completely
await invoke('exit_app');
const setup = async () => {
const handlers: Array<[string, () => void]> = [
['media:play-pause', () => togglePlay()],
['media:next', () => next()],
['media:prev', () => previous()],
['tray:play-pause', () => togglePlay()],
['tray:next', () => next()],
['tray:previous', () => previous()],
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
];
for (const [event, handler] of handlers) {
const u = await listen(event, handler);
if (cancelled) { u(); return; }
unlisten.push(u);
}
}).then(u => unlisten.push(u));
return () => unlisten.forEach(u => u());
}, [togglePlay, next, previous, minimizeToTray]);
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
{
const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(Math.max(0, s.currentTime + e.payload) / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:seek-absolute', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(e.payload / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// window:close-requested is emitted by Rust (prevent_close + emit).
// JS decides: minimize to tray or exit, based on user setting.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await getCurrentWindow().hide();
} else {
await invoke('exit_app');
}
});
if (cancelled) { u(); return; }
unlisten.push(u);
};
setup();
return () => { cancelled = true; unlisten.forEach(u => u()); };
}, [togglePlay, next, previous]);
return null;
}
export default function App() {
const theme = useThemeStore(s => s.theme);
const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => {
document.documentElement.setAttribute('data-font', font);
}, [font]);
useEffect(() => {
return initAudioListeners();
}, []);
useEffect(() => {
useGlobalShortcutsStore.getState().registerAll();
}, []);
// ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ──
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || !e.shiftKey || !e.altKey || e.code !== 'KeyN') return;
e.preventDefault();
setExportPickerOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleExport = async (since: number) => {
setExportPickerOpen(false);
try {
const { exportNewAlbumsImage } = await import('./utils/exportNewAlbums');
const result = await exportNewAlbumsImage(since);
if (result) {
const files = result.paths.length > 1 ? ` (${result.paths.length} Dateien)` : '';
showToast(`📸 ${result.count} Alben exportiert${files}`);
} else {
showToast('📭 Keine Alben in diesem Zeitraum gefunden');
}
} catch (err) {
showToast(`❌ Export fehlgeschlagen: ${String(err).slice(0, 80)}`);
console.error('[easter egg] export failed:', err);
}
};
useEffect(() => {
const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
const onScroll = (e: Event) => {
const el = e.target as HTMLElement;
el.classList.add('is-scrolling');
const existing = timers.get(el);
if (existing !== undefined) clearTimeout(existing);
timers.set(el, setTimeout(() => {
el.classList.remove('is-scrolling');
timers.delete(el);
}, 800));
};
document.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('scroll', onScroll, true);
timers.forEach(t => clearTimeout(t));
};
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
@@ -247,11 +521,14 @@ export default function App() {
path="/*"
element={
<RequireAuth>
<AppShell />
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
</BrowserRouter>
);
}
+302
View File
@@ -0,0 +1,302 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
const API_KEY = '9917fb39049225a13bec225ad6d49054';
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
export function lastfmIsConfigured(): boolean {
return Boolean(API_KEY && API_SECRET);
}
function errMsg(e: unknown): string {
if (typeof e === 'string') return e;
if (e instanceof Error) return e.message;
return String(e);
}
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
const entries = Object.entries(params) as [string, string][];
try {
const result = await invoke('lastfm_request', {
params: entries,
sign,
get,
apiKey: API_KEY,
apiSecret: API_SECRET,
});
// Clear session error on any successful authenticated call
if (sign) useAuthStore.getState().setLastfmSessionError(false);
return result;
} catch (e) {
// Last.fm error codes 4, 9, 14 = auth/session invalid
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
useAuthStore.getState().setLastfmSessionError(true);
}
throw e;
}
}
export async function lastfmGetToken(): Promise<string> {
try {
const data = await call({ method: 'auth.getToken' }, false, true);
return data.token as string;
} catch (e) {
throw new Error(errMsg(e));
}
}
export function lastfmAuthUrl(token: string): string {
return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`;
}
export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> {
try {
const data = await call({ method: 'auth.getSession', token }, true, false);
return { key: data.session.key as string, name: data.session.name as string };
} catch (e) {
throw new Error(errMsg(e));
}
}
export async function lastfmGetSimilarArtists(artistName: string): Promise<string[]> {
try {
const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true);
const artists = data?.similarartists?.artist;
if (!artists) return [];
const arr = Array.isArray(artists) ? artists : [artists];
return arr.map((a: any) => a.name as string);
} catch {
return [];
}
}
export async function lastfmGetAllLovedTracks(
username: string,
sessionKey: string,
): Promise<Array<{ title: string; artist: string }>> {
const results: Array<{ title: string; artist: string }> = [];
let page = 1;
const limit = 200;
while (true) {
try {
const data = await call({
method: 'user.getLovedTracks',
user: username,
sk: sessionKey,
limit: String(limit),
page: String(page),
}, false, true);
const tracks = data?.lovedtracks?.track;
if (!tracks) break;
const arr = Array.isArray(tracks) ? tracks : [tracks];
for (const t of arr) {
results.push({ title: t.name, artist: t.artist?.name ?? '' });
}
const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1);
if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks
page++;
} catch {
break;
}
}
return results;
}
export async function lastfmGetTrackLoved(
title: string,
artist: string,
sessionKey: string,
): Promise<boolean> {
try {
const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true);
return data?.track?.userloved === '1' || data?.track?.userloved === 1;
} catch {
return false;
}
}
export async function lastfmUpdateNowPlaying(
track: { title: string; artist: string; album: string; duration: number },
sessionKey: string,
): Promise<void> {
try {
await call({
method: 'track.updateNowPlaying',
track: track.title,
artist: track.artist,
album: track.album,
duration: String(Math.round(track.duration)),
sk: sessionKey,
}, true, false);
} catch {
// best effort
}
}
export async function lastfmLoveTrack(
track: { title: string; artist: string },
sessionKey: string,
): Promise<void> {
try {
await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
} catch {
// best effort
}
}
export async function lastfmUnloveTrack(
track: { title: string; artist: string },
sessionKey: string,
): Promise<void> {
try {
await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
} catch {
// best effort
}
}
export interface LastfmUserInfo {
playcount: number;
registeredAt: number; // unix timestamp
}
export async function lastfmGetUserInfo(
username: string,
sessionKey: string,
): Promise<LastfmUserInfo | null> {
try {
const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true);
const u = data?.user;
if (!u) return null;
return {
playcount: Number(u.playcount),
registeredAt: Number(u.registered?.unixtime ?? 0),
};
} catch {
return null;
}
}
export interface LastfmRecentTrack {
name: string;
artist: string;
album: string;
timestamp: number | null; // null = currently playing
nowPlaying: boolean;
}
export async function lastfmGetRecentTracks(
username: string,
sessionKey: string,
limit = 20,
): Promise<LastfmRecentTrack[]> {
try {
const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true);
const items = data?.recenttracks?.track;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((t: any) => ({
name: t.name,
artist: t.artist?.['#text'] ?? t.artist?.name ?? '',
album: t.album?.['#text'] ?? '',
timestamp: t.date?.uts ? Number(t.date.uts) : null,
nowPlaying: t['@attr']?.nowplaying === 'true',
}));
} catch {
return [];
}
}
export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month';
export interface LastfmTopArtist {
name: string;
playcount: string;
}
export interface LastfmTopAlbum {
name: string;
playcount: string;
artist: string;
}
export interface LastfmTopTrack {
name: string;
playcount: string;
artist: string;
}
export async function lastfmGetTopArtists(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopArtist[]> {
try {
const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.topartists?.artist;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount }));
} catch {
return [];
}
}
export async function lastfmGetTopAlbums(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopAlbum[]> {
try {
const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.topalbums?.album;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' }));
} catch {
return [];
}
}
export async function lastfmGetTopTracks(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopTrack[]> {
try {
const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.toptracks?.track;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' }));
} catch {
return [];
}
}
export async function lastfmScrobble(
track: { title: string; artist: string; album: string; duration: number },
timestamp: number,
sessionKey: string,
): Promise<void> {
try {
await call({
method: 'track.scrobble',
track: track.title,
artist: track.artist,
album: track.album,
duration: String(Math.round(track.duration)),
timestamp: String(Math.floor(timestamp / 1000)),
sk: sessionKey,
}, true, false);
} catch {
// best effort
}
}
+47
View File
@@ -0,0 +1,47 @@
export interface LrclibLyrics {
syncedLyrics: string | null;
plainLyrics: string | null;
}
export interface LrcLine {
time: number; // seconds
text: string;
}
export async function fetchLyrics(
artist: string,
title: string,
album: string,
duration: number,
): Promise<LrclibLyrics | null> {
const params = new URLSearchParams({
artist_name: artist,
track_name: title,
album_name: album,
duration: Math.round(duration).toString(),
});
try {
const res = await fetch(`https://lrclib.net/api/get?${params}`);
if (!res.ok) return null;
const data = await res.json();
return {
syncedLyrics: data.syncedLyrics ?? null,
plainLyrics: data.plainLyrics ?? null,
};
} catch {
return null;
}
}
export function parseLrc(lrc: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const line of lrc.split('\n')) {
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
if (!match) continue;
const mins = parseInt(match[1], 10);
const secs = parseFloat(match[2]);
const text = match[3].trim();
lines.push({ time: mins * 60 + secs, text });
}
return lines.sort((a, b) => a.time - b.time);
}
+255 -7
View File
@@ -1,6 +1,8 @@
import axios from 'axios';
import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
@@ -13,7 +15,7 @@ function secureRandomSalt(): string {
function getAuthParams(username: string, password: string) {
const salt = secureRandomSalt();
const token = md5(password + salt);
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
}
function getClient() {
@@ -51,6 +53,7 @@ export interface SubsonicAlbum {
genre?: string;
starred?: string;
recordLabel?: string;
created?: string;
}
export interface SubsonicSong {
@@ -72,10 +75,34 @@ export interface SubsonicSong {
contentType?: string;
size?: number;
samplingRate?: number;
bitDepth?: number;
channelCount?: number;
starred?: string;
genre?: string;
path?: string;
albumArtist?: string;
replayGain?: {
trackGain?: number;
albumGain?: number;
trackPeak?: number;
albumPeak?: number;
};
}
export interface InternetRadioStation {
id: string;
name: string;
streamUrl: string;
homepageUrl?: string;
coverArt?: string; // Navidrome v0.61.0+
}
export interface RadioBrowserStation {
stationuuid: string;
name: string;
url: string;
favicon: string;
tags: string;
}
export interface SubsonicPlaylist {
@@ -87,6 +114,8 @@ export interface SubsonicPlaylist {
changed: string;
owner?: string;
public?: boolean;
comment?: string;
coverArt?: string;
}
export interface SubsonicNowPlaying extends SubsonicSong {
@@ -117,6 +146,7 @@ export interface SubsonicArtistInfo {
smallImageUrl?: string;
mediumImageUrl?: string;
largeImageUrl?: string;
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
}
// ─── API Methods ──────────────────────────────────────────────
@@ -158,17 +188,26 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
return data.albumList2?.album ?? [];
}
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size };
const params: Record<string, string | number> = { size, _t: Date.now() };
if (genre) params.genre = genre;
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
return data.randomSongs?.song ?? [];
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
} catch {
return null;
}
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
@@ -211,8 +250,19 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
export async function getGenres(): Promise<SubsonicGenre[]> {
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
return data.genres?.genre ?? [];
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
const raw = data.genres?.genre;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'byGenre', genre, size, offset, _t: Date.now(),
});
const raw = data.albumList2?.album;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
export interface SearchResults {
@@ -354,12 +404,70 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<void> {
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
await api('createPlaylist.view', params);
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
return data.playlist;
}
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 updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
@@ -395,3 +503,143 @@ export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
return [];
}
}
// ─── Internet Radio ───────────────────────────────────────────
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export const RADIO_PAGE_SIZE = 25;
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
// ─── Structured Lyrics (OpenSubsonic / getLyricsBySongId) ────────────────────
export interface SubsonicLyricLine {
start?: number; // milliseconds — absent when unsynced
value: string;
}
export interface SubsonicStructuredLyrics {
issynced: boolean;
lang?: string;
offset?: number;
displayArtist?: string;
displayTitle?: string;
line: SubsonicLyricLine[];
}
/**
* Fetches structured lyrics from the server's embedded tags via the
* OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the
* server doesn't support the endpoint or the track has no embedded lyrics.
* Prefers synced lyrics over plain when both are present.
*/
export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredLyrics | null> {
try {
const data = await api<{ lyricsList: { structuredLyrics?: SubsonicStructuredLyrics[] } }>(
'getLyricsBySongId.view',
{ id },
);
const list = data.lyricsList?.structuredLyrics;
if (!list || list.length === 0) return null;
return list.find(l => l.issynced) ?? list[0];
} catch {
// Server doesn't support the endpoint or track has no embedded lyrics
return null;
}
}
+44 -16
View File
@@ -1,18 +1,29 @@
import React from 'react';
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { Play, HardDriveDownload } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
}
export default function AlbumCard({ album }: AlbumCardProps) {
function AlbumCard({ album }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
return (
<div
@@ -26,14 +37,20 @@ export default function AlbumCard({ album }: AlbumCardProps) {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
}));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="album-card-cover">
@@ -47,21 +64,32 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</svg>
</div>
)}
{isOffline && (
<div className="album-card-offline-badge" aria-label="Offline available">
<HardDriveDownload size={12} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
aria-label={`Details zu ${album.name}`}
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
Details
<Play size={15} fill="currentColor" />
</button>
</div>
</div>
<div className="album-card-info">
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
<p className="album-card-title truncate">{album.name}</p>
<p
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
</div>
</div>
);
}
export default memo(AlbumCard);
+186 -51
View File
@@ -1,13 +1,17 @@
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { 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';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
@@ -49,6 +53,7 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
);
}
interface AlbumInfo {
id: string;
name: string;
@@ -68,10 +73,14 @@ interface AlbumHeaderProps {
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
onToggleStar: () => void;
onDownload: () => void;
onCacheOffline: () => void;
onRemoveOffline: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onBio: () => void;
@@ -86,10 +95,14 @@ export default function AlbumHeader({
resolvedCoverUrl,
isStarred,
downloadProgress,
offlineStatus,
offlineProgress,
bio,
bioOpen,
onToggleStar,
onDownload,
onCacheOffline,
onRemoveOffline,
onPlayAll,
onEnqueueAll,
onBio,
@@ -97,13 +110,23 @@ export default function AlbumHeader({
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [lightboxOpen, setLightboxOpen] = useState(false);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
return (
<>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
{lightboxOpen && info.coverArt && (
<CoverLightbox
src={buildCoverArtUrl(info.coverArt, 2000)}
alt={`${info.name} Cover`}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="album-detail-header">
{resolvedCoverUrl && (
@@ -121,7 +144,14 @@ export default function AlbumHeader({
</button>
<div className="album-detail-hero">
{coverUrl ? (
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
<button
className="album-detail-cover-btn"
onClick={() => setLightboxOpen(true)}
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>
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
@@ -142,6 +172,7 @@ export default function AlbumHeader({
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{formatLabel && <span>· {formatLabel}</span>}
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
@@ -155,53 +186,157 @@ export default function AlbumHeader({
</>
)}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
{isMobile ? (
<div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */}
<div className="album-actions-row album-actions-row--primary">
<button
className="album-icon-btn album-icon-btn--play"
onClick={onPlayAll}
aria-label={t('albumDetail.playAll')}
data-tooltip={t('albumDetail.playAll')}
>
<Play size={24} fill="currentColor" />
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={onEnqueueAll}
aria-label={t('albumDetail.enqueue')}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
aria-label={t('albumDetail.artistBio')}
data-tooltip={t('albumDetail.artistBio')}
>
<ExternalLink size={16} />
</button>
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)}
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)}
</div>
</div>
) : (
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
</div>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-ghost offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)}
</div>
)}
</div>
</div>
</div>
+10 -3
View File
@@ -2,18 +2,19 @@ import React, { useRef, useState, useEffect } from 'react';
import { SubsonicAlbum } from '../api/subsonic';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
titleLink?: string;
albums: SubsonicAlbum[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
}
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
@@ -61,7 +62,13 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
{titleLink ? (
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
{title}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
) : (
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)}
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
+378 -93
View File
@@ -1,20 +1,27 @@
import React from 'react';
import { Play, Star } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic';
import { Track } from '../store/playerStore';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
if (song.bitRate) parts.push(`${song.bitRate}`);
return parts.join(' ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
@@ -40,13 +47,34 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
);
}
// ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
];
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where cell content should be centred (both header and rows)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
interface AlbumTrackListProps {
songs: SubsonicSong[];
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
hoveredSongId: string | null;
setHoveredSongId: (id: string | null) => void;
ratings: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
@@ -60,8 +88,6 @@ export default function AlbumTrackList({
hasVariousArtists,
currentTrack,
isPlaying,
hoveredSongId,
setHoveredSongId,
ratings,
starredSongs,
onPlaySong,
@@ -70,7 +96,55 @@ export default function AlbumTrackList({
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const navigate = useNavigate();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// ── Bulk select ───────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, globalIdx);
const to = Math.max(lastSelectedIdx, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
return next;
});
setLastSelectedIdx(globalIdx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
@@ -81,25 +155,265 @@ export default function AlbumTrackList({
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1;
const makeTrack = (song: SubsonicSong): Track => ({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
});
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
// direction=1: drag right → this column grows, title (1fr) shrinks
const isResizable = !isLastCol;
return (
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (key) {
case 'num':
return (
<div
key="num"
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
{currentTrack?.id === song.id && isPlaying && (
<span className="track-num-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
)}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{song.track ?? '—'}</span>
</div>
);
case 'title':
return (
<div key="title" className="track-info">
<span className="track-title">{song.title}</span>
</div>
);
case 'artist':
return (
<div key="artist" className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>
{song.artist}
</span>
</div>
);
case 'favorite':
return (
<div key="favorite" className="track-star-cell">
<button
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating':
return (
<StarRating
key="rating"
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
);
case 'duration':
return (
<div key="duration" className="track-duration">
{formatDuration(song.duration)}
</div>
);
case 'format':
return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
);
case 'genre':
return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
default:
return null;
}
};
// ── Mobile tracklist ─────────────────────────────────────────────────────
if (isMobile) {
return (
<div className="tracklist-mobile">
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span> CD {discNum}
</div>
)}
{discs.get(discNum)!.map(song => {
const isActive = currentTrack?.id === song.id;
return (
<div
key={song.id}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="tracklist-mobile-main">
{isActive && isPlaying ? (
<span className="tracklist-mobile-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
) : (
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
)}
<span className="tracklist-mobile-title">{song.title}</span>
</div>
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
</div>
);
})}
</div>
))}
</div>
);
}
return (
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div className="tracklist" ref={tracklistRef}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
{/* Column visibility picker */}
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">
{isOn && <Check size={13} />}
</span>
{label}
</button>
);
})}
</div>
)}
</div>
</div>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
@@ -108,79 +422,50 @@ export default function AlbumTrackList({
CD {discNum}
</div>
)}
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
}}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
}}
>
{discs.get(discNum)!.map((song) => {
const globalIdx = songs.indexOf(song);
return (
<div
className="track-num"
style={{
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
toggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
onClick={() => onPlaySong(song)}
>
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
</div>
))}
);
})}
</div>
))}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
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; error?: string }
| { 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: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Update failed:', msg);
// Surface the error so the user (and developer) can see what went wrong
setState({ phase: 'available', version: savedVersion, update, error: msg });
}
};
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">
{state.error && (
<div className="app-updater-error">{state.error}</div>
)}
{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
);
}
+8 -4
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
@@ -11,14 +11,18 @@ interface Props {
export default function ArtistCardLocal({ artist }: Props) {
const navigate = useNavigate();
const coverId = artist.coverArt || artist.id;
// buildCoverArtUrl generates a new crypto salt on every call — must be
// memoized to prevent a new URL on every parent re-render causing refetch loops.
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
return (
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
<div className="artist-card-avatar">
{coverId ? (
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
src={coverSrc}
cacheKey={coverCacheKey}
alt={artist.name}
loading="lazy"
onError={(e) => {
@@ -31,7 +35,7 @@ export default function ArtistCardLocal({ artist }: Props) {
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
<span className="artist-card-name">{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
+5 -41
View File
@@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
artists: SubsonicArtist[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
}
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadingRef = useRef(false);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
// Auto-load trigger
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
triggerLoadMore();
}
};
const triggerLoadMore = async () => {
if (!onLoadMore || loadingRef.current) return;
loadingRef.current = true;
setLoadingMore(true);
await onLoadMore();
setLoadingMore(false);
loadingRef.current = false;
};
useEffect(() => {
@@ -63,35 +43,19 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
{moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import MobileSearchOverlay from './MobileSearchOverlay';
const NAV_ITEMS = [
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
{ to: '/albums', end: false, icon: Music4, labelKey: 'sidebar.allAlbums' },
{ to: '/now-playing', end: false, icon: AudioLines, labelKey: 'sidebar.nowPlaying' },
] as const;
export default function BottomNav() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [searchOpen, setSearchOpen] = useState(false);
return (
<>
<nav className="bottom-nav" aria-label="Mobile navigation">
{NAV_ITEMS.map(({ to, end, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) => `bottom-nav-item${isActive ? ' active' : ''}`}
>
<span className="bottom-nav-icon-wrap">
<Icon size={22} />
{to === '/now-playing' && isPlaying && currentTrack && (
<span className="bottom-nav-np-dot" />
)}
</span>
<span className="bottom-nav-label">{t(labelKey)}</span>
</NavLink>
))}
<button
className="bottom-nav-item"
onClick={() => setSearchOpen(true)}
aria-label={t('search.title')}
>
<span className="bottom-nav-icon-wrap">
<Search size={22} />
</span>
<span className="bottom-nav-label">{t('search.title')}</span>
</button>
</nav>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
</>
);
}
+49 -9
View File
@@ -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> {
@@ -6,18 +6,58 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
cacheKey: string;
}
export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
/**
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
* blob is still resolving useful for <img> tags so the browser starts
* loading immediately. Pass false for CSS background-image consumers that
* should only see a stable blob URL (prevents a double crossfade).
*/
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
const [resolved, setResolved] = useState('');
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
let cancelled = false;
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; };
const controller = new AbortController();
setResolved('');
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
if (!controller.signal.aborted) setResolved(url);
});
return () => { controller.abort(); };
}, [fetchUrl, cacheKey]);
return resolved || fetchUrl;
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
}
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey);
return <img src={resolvedSrc} {...props} />;
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
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
// URL upgrades within the same image — avoids the end-of-load flash.
useEffect(() => {
setLoaded(false);
}, [cacheKey]);
return (
<img
ref={imgRef}
src={resolvedSrc || undefined}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
{...props}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useAuthStore } from '../store/authStore';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
interface Props {
onClose: () => void;
}
export default function ChangelogModal({ onClose }: Props) {
const { t } = useTranslation();
const [dontShow, setDontShow] = useState(false);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
const currentVersionData = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
if (!block) return null;
const lines = block.split('\n');
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
const body = lines.slice(1).join('\n').trim();
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
}, []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
setLastSeenChangelogVersion(version);
onClose();
};
if (!currentVersionData) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
<div
className="eq-popup"
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
>
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
{t('changelog.modalTitle')} v{version}
</span>
{currentVersionData.date && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{currentVersionData.date}
</span>
)}
</div>
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
{currentVersionData.body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderTop: '1px solid var(--border-subtle)',
flexShrink: 0,
gap: '1rem',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
{t('changelog.dontShowAgain')}
</label>
<button className="btn btn-primary" onClick={handleClose}>
{t('changelog.close')}
</button>
</div>
</div>
</>,
document.body
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
interface Props {
status: ConnectionStatus;
isLan: boolean;
serverName: string;
}
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const label = isLan ? 'LAN' : t('connection.extern');
const tooltip =
status === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
return (
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<span className="connection-type">{label}</span>
<span className="connection-server">{serverName}</span>
</div>
</div>
);
}
+337 -51
View File
@@ -1,9 +1,13 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import React, { useEffect, useLayoutEffect, useRef, useState } from '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';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlaylistStore } from '../store/playlistStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
@@ -17,19 +21,164 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download';
}
// ── Add-to-Playlist submenu ───────────────────────────────────────
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [flipLeft, setFlipLeft] = useState(false);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const recentIds = usePlaylistStore((s) => s.recentIds);
useEffect(() => {
getPlaylists().then((all) => {
const sorted = [...all].sort((a, b) => {
const ai = recentIds.indexOf(a.id);
const bi = recentIds.indexOf(b.id);
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
setPlaylists(sorted);
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Flip submenu left if it would overflow the right edge of the viewport
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
}
}, []);
useEffect(() => {
if (creating) newNameRef.current?.focus();
}, [creating]);
const handleAdd = async (pl: SubsonicPlaylist) => {
setAdding(pl.id);
try {
const { songs } = await getPlaylist(pl.id);
const existingIds = new Set(songs.map((s) => s.id));
const newIds = songIds.filter((id) => !existingIds.has(id));
if (newIds.length > 0) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
}
touchPlaylist(pl.id);
} catch {}
setAdding(null);
onDone();
};
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const pl = await createPlaylist(name, songIds);
if (pl?.id) touchPlaylist(pl.id);
} catch {}
onDone();
};
const subStyle: React.CSSProperties = dropDown
? { top: 'calc(100% + 4px)', left: 0, right: 'auto' }
: flipLeft
? { right: 'calc(100% + 4px)', left: 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div className="context-submenu" ref={subRef} style={subStyle}>
{/* New Playlist row */}
{!creating ? (
<div
className="context-menu-item context-submenu-new"
onClick={e => { e.stopPropagation(); setCreating(true); }}
>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{playlists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.empty')}</div>
)}
{playlists.map((pl) => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl)}
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
// Same as AddToPlaylistSubmenu but resolves album songs first
function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: () => void }) {
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
useEffect(() => {
getAlbum(albumId).then((data) => {
setResolvedIds(data.songs.map((s) => s.id));
}).catch(() => setResolvedIds([]));
}, [albumId]);
if (resolvedIds === null) {
return (
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
);
}
if (resolvedIds.length === 0) return null;
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
// Adjusted coordinates to keep menu on screen
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false);
const [playlistSongIds, setPlaylistSongIds] = useState<string[]>([]);
useEffect(() => {
if (contextMenu.isOpen) {
setCoords({ x: contextMenu.x, y: contextMenu.y });
setPlaylistSubmenuOpen(false);
setPlaylistSongIds([]);
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
@@ -50,49 +199,96 @@ export default function ContextMenu() {
const { type, item, queueIndex } = contextMenu;
const isStarred = (id: string, itemStarred?: string) =>
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
const handleAction = async (action: () => void | Promise<void>) => {
closeContextMenu();
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(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
playTrack(radioTracks[0], radioTracks);
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);
}
} catch (e) {
console.error('Failed to start radio', e);
}
};
const downloadAlbum = async (albumName: string, albumId: string) => {
try {
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${sanitizeFilename(albumName)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} catch (e) {
console.error('Download failed:', e);
}
@@ -132,16 +328,23 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
})}>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(songToTrack);
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div>
)}
@@ -151,11 +354,35 @@ 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(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}>
<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 && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setLastfmLovedForSong(song.title, song.artist, newLoved);
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}>
<LastfmIcon size={14} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div>
);
})()}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</>
);
@@ -172,12 +399,28 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
})}>
<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))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
onMouseEnter={() => { setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
<AlbumToPlaylistSubmenu albumId={album.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
</>
);
})()}
@@ -190,8 +433,13 @@ export default function ContextMenu() {
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> {t('contextMenu.favoriteArtist')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
})}>
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
</>
);
@@ -209,15 +457,53 @@ export default function ContextMenu() {
})}>
{t('contextMenu.removeFromQueue')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}>
<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 && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setLastfmLovedForSong(song.title, song.artist, newLoved);
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}>
<LastfmIcon size={14} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div>
);
})()}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</>
);
})()}
+30
View File
@@ -0,0 +1,30 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface Props {
src: string;
alt: string;
onClose: () => void;
}
export default function CoverLightbox({ src, alt, onClose }: Props) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return createPortal(
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
<img
className="cover-lightbox-img"
src={src}
alt={alt}
onClick={e => e.stopPropagation()}
/>
</div>,
document.body
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown } from 'lucide-react';
export interface SelectOption {
value: string;
label: string;
group?: string; // group label — shown as non-selectable header when it changes
disabled?: boolean;
}
interface Props {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
className?: string;
style?: React.CSSProperties;
}
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [dropStyle, setDropStyle] = useState<React.CSSProperties>({});
const selected = options.find(o => o.value === value);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const maxH = 240;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
setDropStyle({
position: 'fixed',
left: rect.left,
width: rect.width,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
className={`custom-select-trigger ${className}`}
style={style}
onClick={() => setOpen(v => !v)}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="custom-select-label">{selected?.label ?? value}</span>
<ChevronDown size={14} className={`custom-select-chevron ${open ? 'open' : ''}`} />
</button>
{open && createPortal(
<div
ref={listRef}
className="custom-select-dropdown"
style={dropStyle}
role="listbox"
>
{options.reduce<React.ReactNode[]>((acc, opt, i) => {
const prevGroup = i > 0 ? options[i - 1].group : undefined;
if (opt.group && opt.group !== prevGroup) {
acc.push(
<div key={`group-${opt.group}`} className="custom-select-group-label">
{opt.group}
</div>
);
}
acc.push(
<div
key={opt.value}
className={`custom-select-option ${opt.value === value ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}`}
role="option"
aria-selected={opt.value === value}
onMouseDown={() => { if (!opt.disabled) { onChange(opt.value); setOpen(false); } }}
>
{opt.label}
</div>
);
return acc;
}, [])}
</div>,
document.body
)}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { FolderOpen } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useAuthStore } from '../store/authStore';
export default function DownloadFolderModal() {
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const { t } = useTranslation();
const handleBrowse = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
if (selected && typeof selected === 'string') setFolder(selected);
};
if (!isOpen) return null;
return (
<>
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
<div className="eq-popup-header">
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
</div>
<div style={{ padding: '16px 0 12px' }}>
<div className="download-folder-pick-row">
<span className="download-folder-path">
{folder || t('common.noFolderSelected')}
</span>
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
<FolderOpen size={15} /> {t('settings.pickFolder')}
</button>
</div>
<label className="download-remember-row">
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
<span>{t('common.rememberDownloadFolder')}</span>
</label>
</div>
<div className="download-modal-actions">
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
<button
className="btn btn-primary"
onClick={() => confirm(setDownloadFolder)}
disabled={!folder}
>
{t('common.download')}
</button>
</div>
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More