Compare commits

...

308 Commits

Author SHA1 Message Date
cucadmuh ef45b726ad fix(changelog): resolve embedded notes by semver core (#373)
Whats New and the post-update modal required an exact ## [version] header,
so prerelease package versions (rc, dev) often had no matching section.
Match on major.minor.patch first; if several sections share the core,
prefer a plain X.Y.Z heading when present.
2026-04-29 22:36:57 +00:00
Frank Stellmacher 1d569f6282 Update CHANGELOG.md (#371) 2026-04-29 23:51:21 +02:00
cucadmuh 106baec8fe chore: align Tauri package version with package.json on main (#370)
Cargo.toml and tauri.conf.json had stale RC-style semver; match root
package.json (1.44.0-dev) so local builds and artifact names stay consistent.
2026-04-29 21:46:59 +00:00
Frank Stellmacher 98644d859f Update CHANGELOG.md (#369) 2026-04-29 23:46:49 +02:00
cucadmuh 10ca1bc051 Feat/promote sync tauri version (#368)
* ci(release): sync Cargo/tauri versions after npm version on promote

Keep bundle artifact names aligned with package.json by updating
src-tauri/Cargo.toml and tauri.conf.json when promoting channel branches.

Made-with: Cursor

* ci(release): sync Cargo/tauri in post-release dev bump PR

Run the same package.json→Tauri sync after bumping main to the next -dev
version so local builds match auto-generated PR contents.
2026-04-29 21:38:48 +00:00
Frank Stellmacher e3ac4500ae Update CHANGELOG.md (#367) 2026-04-29 23:16:20 +02:00
Frank Stellmacher 4b9806ccc0 Update CHANGELOG.md (#366) 2026-04-29 23:13:37 +02:00
Frank Stellmacher 2bea55bedd feat(playlists): suggestion-row preview UX (#365)
* feat(playlists): suggestion-row preview UX (30s preview, double-click play-next, scroll keep)

Reworks the interaction on the suggestion rows below a playlist so users
can audition songs before deciding what to do with them.

- New "Preview" pill button left of each track title plays a 30-second
  mid-song sample via a parallel HTML5 audio element. The main player
  pauses on the first preview and auto-resumes when the preview ends.
  Switching previews chains without re-resuming. External main-player
  playback (spacebar, mediakey) cancels the preview without resuming.
  Animated underline shows the 30 s progress.
- Double-click the row to insert the song at queueIndex + 1 and skip to
  it ("Play next"). Single-click is intentionally inert so a stray click
  next to the preview button no longer drops a song into the queue by
  accident. Tooltip on the row makes the affordance discoverable.
- The + button still adds to the playlist, and now restores the
  .main-content scroll position so the page no longer jumps to the top
  after pressing it.
- i18n keys in all 8 locales: playlists.preview / previewStop / previewShort
  / previewStopShort / suggestionDoubleClickPlayNext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(playlists): WebKitGTK NULL-instance crash on preview teardown

cucadmuh hit GLib-GObject-CRITICAL: invalid (NULL) pointer instance /
g_signal_connect_data assertion failed, with the UI freezing after
clicking a preview button. Root cause was the audio-element lifecycle:

- `audio.src = ''` to "stop" leaves WebKitGTK's GStreamer playbin in a
  half-initialized state. The next signal_connect dereferences NULL.
- Creating `new Audio()` per click stacked half-torn-down playbins
  during rapid switches.
- `loadedmetadata` listeners on orphaned audio elements could call
  play() on an already-discarded instance.

Fix: reuse one <audio> element per component, tear down the source
via removeAttribute('src') + load() (which resets the playbin
cleanly), and gate async listeners behind a session counter so stale
metadata events on switched-away previews are ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(playlists): icon-based action buttons + add-on-doubleclick + hint

- Replace text-pill Preview button with circular icon button + animated
  SVG progress ring (Play/Square icon swap when active).
- New Play-next button (filled accent circle, white triangle) sits
  left of the Preview button — explicit affordance for the action that
  used to be a hidden double-click.
- Double-click on a suggestion row now triggers Add-to-playlist (the
  same action as the + button on the right) — discoverable shortcut
  for mouse users.
- Subtitle under the Suggested Songs header announces the add-on-
  doubleclick affordance, in all 8 locales.
- Drop the now-obsolete previewShort / previewStopShort short-label
  keys (text replaced by icons); rename suggestionDoubleClickPlayNext
  → playNextSuggestion to match its new role on the Play-next button.
- Keep the session-counter guard from the prior commit so async
  loadedmetadata handlers from a switched-away preview can't play()
  on a discarded element.

Note: header column labels visually drift from the data columns when
suggestion rows have the action buttons; left as-is for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(playlists): apply LUFS pre-analysis attenuation to suggestion previews

When the main player is on loudness normalization, analysed tracks come
out reduced toward target while the unanalysed preview <audio> blasts
the file at its natural level — cucadmuh reported the previews are
audibly louder than the playlist playback.

Apply the user's stored pre-analysis attenuation (the slider value, not
the target-offset effective form) as a linear gain on the preview's
audio.volume. Default −4.5 dB lands the preview at ~60% of the player
volume, which roughly tracks how aggressively the Rust engine pulls
naturally-loud tracks toward target.

If the engine is off, behavior is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(playlists): use chevron icon for suggestion preview button

Distinguishes the preview button from the adjacent play-next button
by mirroring the Play Next chevron from the context menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:02:31 +02:00
Frank Stellmacher 70ff025ece Update CHANGELOG.md (#363) 2026-04-29 20:37:17 +03:00
Frank Stellmacher 2ff7ab5697 Update README.md (#362) 2026-04-29 12:54:07 +02:00
Frank Stellmacher fbd6611049 Update CHANGELOG.md (#360) 2026-04-29 11:11:59 +02:00
cucadmuh e15ca83bd5 ci(release): unify GitHub release title format across channels (#359)
Use a single release title pattern (`Psysonic v<version>`) for both RC and
stable releases, while keeping prerelease/draft flags unchanged.
2026-04-29 08:13:17 +00:00
cucadmuh 14c66da087 fix(ci): Update next.yml (#358)
draft_release: true at next channel
2026-04-29 08:07:35 +00:00
cucadmuh 8a38127bf7 fix(queue): respect rating filter in infinite queue top-up (#357)
Apply mix rating settings to infinite queue candidates and keep fetching random batches when filtered results are below the target size, so top-ups can still reach five tracks when possible.
2026-04-29 10:33:50 +03:00
cucadmuh cf24dc0e7b ci(release): skip channel publish on Nix-only pushes (#356)
Avoid feedback loop when merging verify-nix refresh PRs (flake.lock +
nix/) back into next/release.
2026-04-29 01:47:44 +03:00
cucadmuh b7a842395c fix(ci): correct node -p quoting for package.json version in Actions shell (#354)
Single-quoted bash passes backslashes literally; escaped quotes broke Node on v24.
2026-04-29 01:26:23 +03:00
cucadmuh ba9f5728b3 ci(release): always checkout next/release for channel publish (#353)
workflow_dispatch previously used github.ref_name for source_ref, so
running Next Channel from branch `next` loaded stale reusable workflows
and could recreate the invalid `app-v` tag. Pin source_ref to the
channel branch; document running manual workflows from `main` for
latest publish YAML.
2026-04-29 01:20:35 +03:00
cucadmuh 2a08115ba8 ci(release): reject empty package.json version before app-v release tag (#352)
An empty VERSION produced the invalid GitHub tag "app-v" and broke
update-manifest downloads (release tag mismatch).

Validate semver prefix, write package version via heredoc to GITHUB_OUTPUT,
and refuse to compute tag app-v.

Made-with: Cursor
2026-04-29 00:55:59 +03:00
cucadmuh 133e09ec63 ci(release): run channel publish after promote when GITHUB_TOKEN push skips push rules (#350)
GitHub does not fire push workflows for commits pushed with the default
actions token. Chain Next/Release channel workflows from the matching
promote workflow_run on success.

Made-with: Cursor
2026-04-29 00:36:26 +03:00
cucadmuh a9573625f4 ci(release): gate promote-main on explicit main checks (#349)
* ci(release): gate promote-main on explicit main checks

Add an always-on ci-main workflow that produces a stable check name, and validate that check via check runs (not branch protection APIs). Wire promote-main-to-next to require ci-main / ci-ok and document the required check in the release SOP.

* ci(release): accept ci-ok or UI-style name in main promote gate

GitHub check run names for normal workflows are often the job name only;
keep optional match for the UI-style workflow/job label. Drop unused
statuses:read permission.

Made-with: Cursor
2026-04-29 00:22:51 +03:00
cucadmuh 8fe75b3d74 ci(release): ignore current run when validating main checks (#348)
Exclude check runs and status contexts from the current workflow run in the validation fallback path so the promote gate does not fail on its own in-progress job when branch protection contexts are unavailable.
2026-04-28 22:48:11 +02:00
cucadmuh 408052adc8 ci(release): validate only required main checks before promote (#347)
Fix the main-branch promotion gate to evaluate required status contexts from branch protection instead of all check runs, preventing self-blocking while the validator job is still in progress.
2026-04-28 22:43:22 +02:00
cucadmuh 42c6b2274d chore(release): prepare 1.44.0 dev version and aur tag source (#346)
* chore(release): prepare 1.44.0 dev version and aur tag source

Set the app version to 1.44.0-dev in package manifests and update the AUR source tag template to app-v$pkgver while keeping pkgver on the current stable release.

* chore(nix): sync npmDepsHash with package-lock.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-28 23:34:03 +03:00
cucadmuh bf38288feb ci(release): split channel pipelines and automate version transitions (#345)
* ci(release): split channel pipelines and automate version transitions

Introduce dedicated next/release orchestration workflows backed by a reusable publish pipeline with RC/final package version updates and post-release main dev bump PRs. Add a strict release process SOP with RC freeze, hotfix override, and mandatory backport rules.

* ci(release): gate main-to-next promotion on green CI

Add a dedicated workflow that validates main branch checks/statuses and block the promote-main-to-next flow unless main is green. Update the release SOP to reflect the enforced pre-promotion validation.

* ci(release): fix channel promotion edge cases and policy gaps

Switch channel promotions to reset-based snapshots with force-with-lease pushes, stop auto-merging channel nix-refresh PRs, and guard main dev bump against version downgrades. Add RC changelog fallback, require AUR release updates in SOP, and extend npmDepsHash sync to main/next/release pushes.

* docs(release): clarify backport and nix refresh survivability rules

Require RC fix backports to reach main before the next main-to-next promotion, and document that channel-local nix refresh PRs are advisory unless equivalent changes are merged into main.
2026-04-28 23:20:01 +03:00
cucadmuh 8141c5213f fix(player): reserve preview row space in delay modal (#344)
Prevent the timer modal from shifting when the "Paused at/Starts at" preview appears on hover.
2026-04-28 09:23:19 +03:00
github-actions[bot] bce65be287 chore(nix): refresh lock + npmDepsHash for v1.44.0-rc1 (#341)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-27 18:20:47 +00:00
cucadmuh cfd7fb369a ci(nix): auto-sync npmDepsHash when package-lock changes (#340)
Add a GitHub Actions workflow that recomputes prefetch-npm-deps from
package-lock.json and updates nix/upstream-sources.json so Nix builds stay
aligned without manual hash edits.
2026-04-27 21:20:19 +03:00
Frank Stellmacher 402120143e chore(release): bump to 1.44.0-rc1 (#339)
CHANGELOG entry covers everything merged on top of v1.43.0:
LUFS loudness normalization, Orbit multi-user listen-together,
Now-Playing dashboard, Tracks library hub, Lucky Mix, sleep-timer
ring UI, Genres tag-cloud, queue undo/redo, settings refactor +
perf suite, plus the usual fixes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:02:51 +02:00
cucadmuh bd2242e4f3 feat(cli): add logs mode with tail/follow and completion support (#337)
Introduce a dedicated --logs mode for CLI log viewing with --tail <lines> and
-f/--follow streaming from the normal/debug log channel, keep user-facing CLI
output free of timestamped log formatting, and update bash/zsh completions for
the new logs flags.
2026-04-27 20:21:41 +03:00
Frank Stellmacher 8967ca825d chore(credits): catch up Settings contributors for #261, #324–#336 (#338)
- Move PR #261 (library deep links / psysonic2 scheme) from
  Psychotoxical to cucadmuh — original author
- Add cucadmuh: #324, #326, #331, #332, #333
- Add Psychotoxical: #328, #329, #330, #336

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:58:17 +02:00
Frank Stellmacher d2080588b9 fix(player): persist queue panel visibility + drop dead loudness binding (#336)
* feat(player): persist queue panel visibility across restarts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(audio): drop unused resolved_loudness_gain_db binding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:37:22 +02:00
cucadmuh 87373edb17 fix(loudness): target sync, effective pre-analysis trim, and queue/settings copy (#333)
* fix(loudness): target sync, -14 pre-analysis ref, queue UI, and reseed

- Front: coalesce loudness refresh by target LUFS; replay-gain IPC dedupe keys
  include norm target and effective pre-attenuation so TGT changes apply.
- Rust: placeholder gain before integrated LUFS uses pivot at -14 LUFS; UI gain
  from effective trim; reseed loudness after delete when waveform cache would skip.
- Pre-analysis: store attenuation relative to -14 LUFS; engine and UI use an
  offset for other targets; migrate legacy absolute values on rehydrate.
- Queue/Settings: Loudness/TGT labels vs value buttons; styles; i18n for help.

* fix(i18n): simplify loudness pre-analysis helper copy

Remove reference-target wording from loudness pre-analysis helper text and keep
only the effective adjustment shown for the current LUFS target in all locales.
2026-04-27 02:54:58 +03:00
cucadmuh cf09fd4bd3 fix(player): Lucky Mix + mix rating filter (Navidrome / OpenSubsonic) (#332)
* fix(player): Lucky Mix respects mix rating filter and harden rating reads

Wire Lucky Mix through the same Settings → Ratings filter as Random Mix
(enrich + passesMixMinRatings). Prefetch artist/album ratings via
getArtist/getAlbum using both userRating and Navidrome-style rating,
and bump the in-memory prefetch cache key so stale misses are not reused.
Resolve artist entity id from OpenSubsonic artists[] or contributors[]
when top-level artistId is missing; read rating on child song and artist
refs so thresholds apply consistently.

* chore: clarify JSDoc for mix rating enrich vs Lucky Mix filter
2026-04-27 01:30:41 +03:00
cucadmuh 8a97a35ce6 Merge pull request #331 from Psychotoxical/feat/player-queue-undo-redo
feat(player): queue undo/redo with hotkeys, playback-aware restore, and scroll restoration
2026-04-27 01:05:49 +03:00
Maxim Isaev 55a49a9fb0 feat(player): restore queue list scroll on queue undo/redo
Store the main queue panel viewport scrollTop in undo snapshots and
reapply it after undo/redo so Ctrl+Z / Ctrl+Shift+Z restores scroll
position alongside queue state.
2026-04-27 00:33:47 +03:00
Frank Stellmacher c64a1e195e chore(discord): debug-build logging for Rich Presence IPC path (#330)
The Discord Rich Presence module deliberately swallows every IPC error so
the app stays clean when Discord is not running. The downside: when the
status genuinely stops working (Discord client glitches, app id rejected,
socket pipe stuck), nothing is logged anywhere and we have no signal.

Add debug-build logging at every step of the IPC handshake and every send:

- try_connect: separate failure logs for new() vs connect(), success log
  with the app id.
- discord_update_presence: log the rendered details/state on send, log
  set_activity failures with the underlying error.
- discord_clear_presence: log clear success and clear failures.
- The pause-path clear_activity inside discord_update_presence likewise
  logs failures.

All log lines are wrapped in #[cfg(debug_assertions)] so they compile out
of release builds — no runtime cost or log noise for end users.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:25:20 +02:00
Maxim Isaev 39f4d03da5 feat(player): queue undo/redo with hotkeys and playback-aware restore
Snapshot queue, current track, time, and pause before queue mutations.
Ctrl+Z/Cmd+Z restores prior state; Ctrl+Shift+Z/Cmd+Shift+Z reapplies undone
edits when the redo stack is non-empty; new edits clear redo.

Resync the Rust audio engine only when the current song identity changes
from the pre-apply state so reorder/enqueue-style edits keep live playback.

Register the document capture listener from main.tsx after the window label
is set. Mini player forwards undo/redo via mini:undo-queue and mini:redo-queue.
2026-04-27 00:17:10 +03:00
Frank Stellmacher e0c53da94d feat(playlists): confirm before adding songs that are already in the playlist (#329)
Closes #327.

When every selected song is already in the target playlist, the
"Add to Playlist" flow used to silently show an "all skipped" toast.
Users could not tell whether they had hit the wrong target or whether
the action was deliberately ignored, and they had no way to add
duplicates intentionally (e.g. for a song they actually want twice).

New behavior: if every id is a duplicate, ask via the existing
GlobalConfirmModal — "Add anyway" appends them as duplicates,
"Cancel" keeps the previous silent-skip toast.

Mixed cases (some new, some duplicate) and the all-new path are
unchanged. Applied at all three add-to-playlist call sites in the
context menu (single/multi-track, album selection, artist selection).
Strings added to all 8 locales (en, de, es, fr, nl, nb, ru, zh).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:13:13 +02:00
Frank Stellmacher 9fe81ee6f6 feat(login): add language picker on the login page (#328)
The language selector previously lived only in Settings, which is
behind login. New users on a non-English system had no way to switch
to their language before connecting to a server.

Add a compact CustomSelect in the top-right of the login card. Reuses
the existing settings.languageXx labels and i18n.changeLanguage flow
(persists to localStorage as psysonic_language). English remains the
default for first launches — already the case in i18n.ts:12.

Styled to be visually quieter than the Settings variant (transparent
background, smaller font) so it doesn't pull focus from the logo and
form.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:58:06 +02:00
cucadmuh d67db230ad Merge pull request #326 from Psychotoxical/fix/loudness-lufs-and-analysis-cpu-seed-queue
fix(analysis,waveform): stabilize LUFS loop, serialize CPU seed work, and introduce mixed mean/peak waveform bins
2026-04-26 22:14:57 +03:00
Maxim Isaev 8f603e8de9 feat(waveform): store peak+mean bins and blend at 70/30
Persist waveform v4 as paired curves per bin (peak and mean absolute value),
invalidate cache rows with insufficient data shape, and render the seekbar as
0.7 * mean + 0.3 * peak for a denser, more stable contour.

Thanks to @peri4ko for proposing this waveform improvement.
2026-04-26 21:43:39 +03:00
cucadmuh 218aa00718 fix(analysis): CPU seed queue, single waveform emit, and log URL redaction
Serialize heavy PCM seeding through a dedicated queue with optional priority
for the current track. Emit waveform-updated once per completed seed, fix
Lucky Mix waveform refresh tokens, redact Subsonic URLs in logs, and align
hot-cache prefetch with the queued path.
2026-04-26 20:59:33 +03:00
Psychotoxical 5cb233e1c9 fix(analysis): dedupe concurrent seed_from_bytes for the same track_id
Six independent paths reach analysis_cache::seed_from_bytes for the same
track_id during a cache-miss play under LUFS:

- track_download_task legacy stream capture-complete
- ranged_download_task HTTP buffer full
- audio_preload bytes-ready
- psysonic-local file-read complete
- spawn_analysis_seed_from_in_memory_bytes (gapless reuse / preloaded /
  stream cache)
- analysis_enqueue_seed_from_url (frontend backfill triggered by
  refresh:miss while a playback path is mid-seed)

Whenever a track is streamed for the first time with loudness on, two of
these fire in parallel and Symphonia + EBU R128 decode the same buffer
twice — ~30 s of duplicate CPU per track, plus a redundant SQLite write
of an identical row. Reproduced on multiple cache-miss tracks (KjP… ranged
+ backfill, mPdz… preloaded bytes + backfill).

Coordinate at the funnel:

- New SeedFromBytesOutcome::SkippedConcurrent variant.
- SEEDS_IN_FLIGHT static (Mutex<HashSet<String>>) tracks track_ids whose
  analysis is currently running. First caller into seed_from_bytes
  wins the slot via HashSet::insert; later callers return SkippedConcurrent
  immediately and let the winner publish the analysis:waveform-updated
  event.
- SeedInFlightGuard (RAII) releases the slot on every exit path including
  panics, so a crashing analysis cannot leak the marker.
- enqueue_analysis_seed in lib.rs distinguishes the SkippedConcurrent log
  from the genuine "seed result" log so the frontend backfill case
  (when it arrives second) doesn't read like a backfill failure.

Existing call-site match arms in audio.rs already use Ok(_) => {} for
non-Upserted outcomes — no changes needed there. enqueue_analysis_seed in
lib.rs already gates the waveform-updated emit on outcome == Upserted, so
the new variant is silent there too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:59:32 +03:00
Psychotoxical 2d222e2691 fix(loudness): break feedback loop that froze Windows UI when LUFS active
When loudness normalization is on, every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS
(900 ms per active stream task) the backend emits analysis:loudness-partial.
The frontend listener wrote cachedLoudnessGainByTrackId and unconditionally
called updateReplayGainForCurrentTrack, which invoked audio_update_replay_gain,
which in turn emitted audio:normalization-state back to the renderer — closing
a loop the UI had to drain on every tick. WebKitGTK absorbed it; WebView2 did
not, and after a few minutes the renderer thread stalled while Rust analysis
tasks kept progressing (visible in logs).

Five gates close the loop, smallest first:

Frontend (src/store/playerStore.ts):
- analysis:loudness-partial listener now short-circuits when the new gainDb is
  within 0.05 dB of the cached value.
- audio_update_replay_gain calls go through invokeAudioUpdateReplayGainDeduped
  (250 ms key-based dedupe), mirroring the audio_set_normalization helper from
  #320.
- refreshLoudnessForTrack now coalesces concurrent calls per (trackId, mode)
  via loudnessRefreshInflight, mirroring waveformRefreshInflight.

Backend (src-tauri/src/audio.rs):
- audio:normalization-state emits go through maybe_emit_normalization_state,
  which keeps the last-emitted payload behind a Mutex and skips when engine
  matches and current_gain_db (±0.05 dB) / target_lufs (±0.02) are unchanged.
  Applied at all three emit sites: audio_play, audio_update_replay_gain,
  audio_set_normalization.
- analysis:loudness-partial emits are gated by partial_loudness_should_emit:
  per-track-identity last-emitted-gain map; an emit is suppressed when the new
  gain is within PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB (0.1 dB) of the previous
  one. Applied to both emit_partial_loudness_from_bytes (legacy path) and the
  inline ranged_download_task progress emit.

Drive-by: split estimated_output_latency_secs into two #[cfg]-gated
definitions so the sample_rate_hz parameter is no longer flagged as unused on
Windows / macOS builds. Function originally added in c6fc3ec.

Net effect: when loudness is steady (the common case), zero IPC traffic on
this path. UI stays responsive on Windows. Tested on Windows + Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:59:32 +03:00
cucadmuh faa931ff68 Merge pull request #324 from Psychotoxical/fix/queue-resizer-hit-test
fix(ui): queue panel resize handle no longer blocked by main scroll hit-test
2026-04-26 17:52:52 +03:00
Maxim Isaev 90a6f09b9f fix(ui): queue panel resize handle no longer blocked by main scroll hit-test
The queue resizer overlaps the main column by a few pixels; treating
clientX <= mainRight as "main" suppressed grabs on that strip. Cap overlay
thumb horizontal slop at mainRight so it does not steal hits on the
resizer. Sidebar / other overlay thumbs stay out of scope via selector.
2026-04-26 17:51:31 +03:00
cucadmuh 67a100ee3f Merge pull request #323 from Psychotoxical/feat/song-info-double-click-copy
feat(ui): copy song fields from song info via double-click
2026-04-26 17:37:25 +03:00
Maxim Isaev f92cfa183d feat(ui): copy song fields from song info via double-click
Double-click the Title, Artist, or Album value to copy plain text to the
clipboard with toast feedback. Apply user-select: none on those cells so
double-click does not trigger word selection.
2026-04-26 17:33:58 +03:00
cucadmuh 1dcc1e101c Merge pull request #322 from Psychotoxical/fix/context-menu-album-play-next-smart-playlists
fix(ui): queue full album after current; filter smart playlists in add targets
2026-04-26 17:24:15 +03:00
Maxim Isaev 577950dc22 fix(ui): queue full album after current; filter smart playlists in add targets
Album context menu loads tracks and inserts them immediately after the
current queue position, or starts album playback when nothing is playing.
Add-to-playlist and merge-target submenus omit psy-smart-* playlist names
so dynamic Navidrome lists are not offered as manual append destinations.
2026-04-26 17:22:17 +03:00
cucadmuh a910162cfe fix(analysis): gate partial LUFS, seed RAM paths, clarify logs, dedupe IPC (#320)
- Emit streaming partial loudness only when the loudness normalization engine is active
- Seed waveform/loudness from in-memory full buffers (preloaded, stream cache, gapless reuse) so offline hot cache does not rely on HTTP backfill
- Add explicit ranged/local/preload logs before full-file Symphonia + EBU analysis
- Coalesce concurrent waveform DB reads and skip duplicate audio_set_normalization within 450ms (e.g. StrictMode double init)
2026-04-26 14:25:26 +02:00
Frank Stellmacher c4a283b809 fix(imageCache): share refcounted blob URLs across consumers (Windows perf) (#321)
The previous design handed every <img> its own URL.createObjectURL for the
same cached Blob. WebKitGTK shrugged it off, but Chromium/WebView2 keys
its decoded-image cache by URL — so identical thumbnails were re-decoded
once per instance. On Windows this made cover/artist grids painfully slow
even when blobs were warm in memory.

Refactor the URL layer to be refcounted and shared:

- New acquireUrl(cacheKey) / releaseUrl(cacheKey) API. First acquire
  creates the URL; subsequent acquires return the same string and bump
  the refcount. Revoke is deferred 500 ms after the count hits zero so
  in-flight decodes finish cleanly.
- useCachedUrl uses a lazy useState initializer: when the blob is hot,
  the very first <img src> is already the blob URL. No fetchUrl→blobUrl
  swap, no decode thrash, no race against the LRU.
- CachedImage passes fallbackToFetch=false: previously the <img>
  briefly carried the raw server URL while the blob resolved, which
  triggered an HTTP fetch that the browser then aborted when src
  flipped to blob: — visible in DevTools as a flood of "Pending / 0 B"
  requests. Memory hits remain instant via the synchronous acquire
  path; cold paths now do a single fetch via getCachedBlob.
- invalidateCacheKey / clearImageCache now also purge URL entries.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:17:12 +02:00
Frank Stellmacher 6a39c5aaa1 Merge pull request #319 from Psychotoxical/chore/credits-v1.44
chore(credits): catch up Settings contributors list for v1.44
2026-04-26 11:55:50 +02:00
Psychotoxical 8b30d3bdfa chore(credits): catch up Settings contributors list for v1.44
Append-only update of CONTRIBUTORS in Settings.tsx for the v1.44 cycle.
cucadmuh gains the medulla-perch perf fix (PR #283), Navidrome smart
playlists (PR #289), and the LUFS loudness cache (PR #315).
Psychotoxical gains 20 entries since the sleep-timer ring (PR #272),
including Orbit (PR #304), the Tracks hub (PR #300), the genres tag
cloud (PR #311), the seekbar truewave/pseudowave split (PR #316),
and the cross-device resume fix (PR #318).

Skipped chore/CI/internal PRs: #285–#287 (credits self-update), #292–
#297 (Flatpak test tags + revert), #306–#310 (deps + devtools), #312
(debug log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:55:30 +02:00
Frank Stellmacher 8a6ff04b05 Merge pull request #318 from Psychotoxical/fix/cross-device-resume
fix(player): cross-device resume — push queue position on pause + all exit paths
2026-04-26 11:15:41 +02:00
Psychotoxical 4217e8e6a0 fix(player): cross-device resume — push queue position on pause + all exit paths
Server queue position was effectively pinned at 0 because syncQueueToServer
only fired on track-change, seek, and queue edits (with a 5s debounce). A
user listening for minutes without seeking would close the app and the
server still held position=0, so a second device restarted the track.

Three pieces:
- 15s heartbeat from audio:progress flushes the live position while playing.
- pause() flushes immediately so a quick close after pause is captured.
- Tray "Exit" and macOS Cmd+Q used to call app.exit(0) directly, bypassing
  the JS close handler. Both now emit a new app:force-quit event that
  shares the same flush + Orbit-teardown path as window:close-requested,
  and exit_app stops the audio engine on its way out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:15:12 +02:00
Frank Stellmacher c674651d35 Merge pull request #317 from Psychotoxical/ui/normalization-settings-refactor
ui(settings): restructure Normalization section
2026-04-26 03:11:43 +02:00
Psychotoxical 756b189bcc ui(settings): restructure Normalization section for clarity and breathing room
The mode picker (Off / ReplayGain / LUFS) used to live in the right-hand
action slot of a settings-toggle-row and the per-mode controls were
stacked into the same parent with footer-style help text. Hard to scan,
visually cramped, and odd compared to the rest of the Settings page.

Refactor:

- Mode picker becomes a full-width segmented row with even-flex buttons,
  using the new .settings-segmented utility.
- Each mode renders its own .settings-norm-block sub-section with a
  subtle accent tint and border so the active configuration reads as
  one coherent group.
- Inside the block, every setting is its own .settings-norm-field
  (control row + per-control help text immediately below). 1.1 rem gap
  between fields, 0.45 rem between row and help — clearly groups
  related text without crowding.
- Sliders no longer max-cap at 200 px and instead flex to fill the row.
- Inactive ghost buttons (Off, ReplayGain, RG mode, LUFS targets) get a
  visible border and a faint surface tint so they read as selectable
  slots in dark themes too.
- LUFS mode gets a dedicated note-box explaining that brief volume drift
  on the very first play of a new track is the analysis pass at work,
  not a bug — subsequent plays use the cached measurement, and queued
  tracks are usually pre-analysed during the previous song.
- "Trim before measurement (dB)" renamed to "Pre-analysis attenuation"
  (and equivalents in 8 locales).
- New i18n keys: normalizationDesc, normalizationOff/ReplayGain/Lufs,
  loudnessTargetLufsDesc, loudnessFirstPlayNote, replayGainPreGainDesc,
  replayGainFallbackDesc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:10:47 +02:00
Frank Stellmacher d467ea1779 Merge pull request #316 from Psychotoxical/fix/seekbar-truewave-pseudowave-split
fix(seekbar): split waveform style into truewave + pseudowave
2026-04-26 02:47:37 +02:00
Psychotoxical ed76090a54 fix(seekbar): split waveform style into truewave (analyzed) + pseudowave (deterministic)
The waveform-loudness-cache merge replaced the existing deterministic
per-track-ID waveform with a bins-based one driven by the analysis
cache. The bins-based variant is the better default but the old
deterministic look is still valuable when no analysis is available
(brand-new track, cache-miss, etc.) and several users prefer it.

Split into two explicit options in the seekbar style picker:

- 'truewave' (default, replaces old 'waveform') — bins from the analysis
  cache, with morph-on-arrival animation and flat-line fallback while
  empty.
- 'pseudowave' — pseudo-random heights derived deterministically from
  the track ID. No analysis dependency, no morph, instant render.

Existing persisted seekbarStyle: 'waveform' is migrated to 'truewave'
in onRehydrateStorage so users keep the visual they have today. The
useEffect that builds heightsRef now lists seekbarStyle in its deps so
switching between the two is live.

i18n labels added in all 8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:45:51 +02:00
cucadmuh 3c1611270a Merge pull request #315 from Psychotoxical/feat/waveform-loudness-cache
feat(playback): waveform loudness cache (EBU R128 + persistent analysis)
2026-04-26 03:29:54 +03:00
Psychotoxical 185cb8f7cd Merge branch 'main' into feat/waveform-loudness-cache 2026-04-26 02:05:10 +02:00
Frank Stellmacher f95391318f Merge pull request #314 from Psychotoxical/fix/queue-preserve-context-on-manual-click
fix(queue): preserve scroll context on manual queue click
2026-04-26 01:41:55 +02:00
Psychotoxical 77b2a5401a fix(queue): preserve scroll context when user clicks a track in the queue
Auto-scroll used to fire on every currentTrack change, including manual
clicks inside the queue list itself. The clicked track would slide off
screen as the list rebased onto the new "next track", which is
disorienting — the user just acted on something specific and expects to
keep seeing it.

Set a one-shot suppression flag from the queue-item onClick handler so
the immediately following auto-scroll effect skips its scrollIntoView
call. Natural advance (track end, prev/next button, anything that does
not originate inside the queue list) leaves the flag untouched and keeps
the original "show what's coming next" behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:41:25 +02:00
Maxim Isaev ea93f7fc4d fix(player): keep seekbar waveform on current track during gapless preload
Byte-preload for the next track called refreshWaveformForTrack(next), which
writes global waveformBins and replaced the playing track’s waveform when
seeking near the end. Drop that call and ignore stale RPC results if
currentTrack changed while the fetch was in flight.
2026-04-26 02:36:38 +03:00
Frank Stellmacher 0667d96085 Merge pull request #313 from Psychotoxical/fix/image-cache-per-component-urls-clean
fix(imageCache): per-component object URLs (no shared LRU)
2026-04-26 01:33:09 +02:00
Psychotoxical ab1b1dcffa fix(imageCache): give every cached <img> its own object URL
The previous design kept a single global Map<cacheKey, objectURL> with an
LRU cap of 150 and aggressively revoked the oldest URL on overflow. On
libraries with more than 150 cached covers (artist + album grids quickly
exceed that), an in-use URL would get revoked because a different
consumer pushed it out of the cache, producing the "Failed to load
resource: blob:..." flood that several users have reported.

Refactor the cache to be blob-centric:

- Public API is now getCachedBlob() returning the Blob itself.
- In-memory LRU now holds Blobs (cap 200), not URLs. Map-entry eviction
  drops the strong reference and lets the GC free the Blob once no
  consumer (object URL, <img>, <canvas>) still holds it. No revoke choreography needed.
- useCachedUrl creates its own URL.createObjectURL on blob arrival and
  revokes it on cleanup with a 500 ms grace delay so the DOM <img> has
  time to finish decoding the URL we just took away.
- All existing callers keep their string-returning API; no call-site
  changes outside the hook itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:32:17 +02:00
Maxim Isaev 4b60495e38 feat(playback): loudness bind rules, analysis seeding, and normalization UI
Resolve integrated loudness from SQLite only at decode bind; keep pre-trim
until a row exists, then allow provisional gain from live updates. Pass
DB-stable hints from the web app into play and gapless preload.

Default pre-measurement attenuation -4.5 dB with an icon reset in Settings;
drop redundant normalization copy and shorten pre-trim descriptions.

analysis_cache seed_from_bytes returns an outcome and skips redundant
waveform work on cache hits; wire callers and related frontend/backend glue.
2026-04-26 02:29:54 +03:00
Frank Stellmacher 0d248d655e Merge pull request #312 from Psychotoxical/chore/log-ping-failure
chore(subsonic): log pingWithCredentials failures
2026-04-26 00:43:51 +02:00
Psychotoxical 4a0bdfed92 chore(subsonic): log pingWithCredentials failures to console
Server-switch silently returns { ok: false } when the upstream ping
throws, so the user never sees why the switch refused. Surfacing the
underlying error (timeout, network, CORS, 401) gives us something to
work with the next time the issue reproduces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:42:44 +02:00
Frank Stellmacher a41628bdf9 Merge pull request #311 from Psychotoxical/perf/genres-tagcloud
perf(genres): replace icon cards with tag-cloud pills
2026-04-26 00:01:29 +02:00
Psychotoxical d31291a463 perf(genres): replace icon cards with tag-cloud pills
The previous genre grid mounted ~60 Lucide SVG cards per page (with
Watermark icon, gradient bg, infinite scroll) and froze the WebKitGTK
renderer for several seconds on libraries with many genres.

The new layout flows all genres as compact pills with log-scaled font
size based on albumCount — one <span>-equivalent button per genre, no
SVGs, no pagination needed. Pill colour is dimly tinted by the same
deterministic hash-to-CTP palette used before; text picks up the genre
colour on hover only, so the page reads calmly at rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:58:09 +02:00
Maxim Isaev c6fc3ec844 fix(waveform): stabilize progressive rendering and reduce streaming contention
Improve seekbar fallback behavior by using consistent bar-based rendering and smoother transitions to analyzed waveform data. Reduce hot-path analysis overhead during ranged streaming and add controls to clear cached waveform entries.
2026-04-26 00:49:10 +03:00
Maxim Isaev 53cab7654c feat(player,queue): loudness strip controls and normalization readout fixes
- Add Tauri command to delete loudness_cache rows for a track and helpers in AnalysisCache.
- Queue tech strip: click dB to reseed loudness; LUFS target picker via body portal; metric styling aligned with strip (no link chrome).
- Player store: reseed clears local cache and replays analysis seed; show loudness dB from SQLite cache when live state is still null; allow first numeric normalization-state update through the short duplicate filter.
- audio_update_replay_gain: resolve loudness from the requested gain when playback URL is not pinned yet.
2026-04-25 23:38:27 +03:00
Frank Stellmacher 9306f0af2c Merge pull request #310 from Psychotoxical/chore/devtools-no-autoopen
chore(tauri): don't auto-open devtools on launch
2026-04-25 22:17:37 +02:00
Psychotoxical 2fae1b4c0e chore(tauri): don't auto-open devtools on launch
Removes the #[cfg(debug_assertions)] open_devtools() block from setup().
DevTools remain available in dev builds via the standard WebKit shortcut
(Ctrl+Shift+I) and right-click → Inspect, but no longer pop up automatically
on every dev launch. Less visual clutter when iterating without needing the
inspector.

Production behaviour is unchanged — devtools stay hard-stripped from
release builds via Tauri's default `windows[].devtools=false` for release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:17:16 +02:00
Psychotoxical 453151f1fc Merge branch 'main' into feat/waveform-loudness-cache
# Conflicts:
#	src/store/playerStore.ts
2026-04-25 21:47:48 +02:00
Maxim Isaev e009fd1823 fix(settings): make normalization modes exclusive and refine LUFS UI
Replace the mixed ReplayGain/Loudness controls with a single exclusive mode selector, hide RG-specific controls when LUFS is active, and align the queue tech badge behavior between RG and LUFS. Also update LUFS targets to -10..-16 ordering and default loudness target to -12.
2026-04-25 22:36:55 +03:00
Maxim Isaev 86704473ed fix(audio): stabilize loudness normalization and streaming startup behavior
Improve normalization consistency by separating cache refresh for non-playing tracks, hardening track-id cache lookups, and keeping UI gain state aligned with actual playback. Also reduce startup stalls by preferring non-seekable streaming when format hints are missing and by tuning ALSA/analysis paths to avoid underrun-prone churn.
2026-04-25 22:09:38 +03:00
Frank Stellmacher e15c0c3d36 Merge pull request #309 from Psychotoxical/Psychotoxical-patch-1
Update README.md
2026-04-25 20:38:04 +02:00
Frank Stellmacher 9048e9426e Update README.md 2026-04-25 20:37:53 +02:00
Frank Stellmacher e2970bcaac Merge pull request #308 from Psychotoxical/Psychotoxical-patch-1
Update README.md
2026-04-25 20:36:44 +02:00
Frank Stellmacher 3672504aaf Update README.md 2026-04-25 20:36:27 +02:00
Frank Stellmacher f3acb795fb Merge pull request #306 from Psychotoxical/chore/deps-rustls-webpki-postcss
chore(deps): bump rustls-webpki + postcss
2026-04-25 20:25:31 +02:00
Psychotoxical b4b786972e chore(deps): bump rustls-webpki to 0.103.13 and postcss to 8.5.10
Patches two Dependabot alerts:
- rustls-webpki 0.103.12 → 0.103.13 (high: DoS via panic on malformed CRL BIT STRING)
- postcss 8.5.8 → 8.5.10 (medium: XSS via unescaped </style> in CSS stringify output)

Both are semver-compatible patch bumps. cargo check + npm run build pass.

The remaining open Dependabot alert (glib 0.18 → 0.20) is blocked on
upstream tauri/wry/webkit2gtk pinning the gtk-rs 0.18 stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:23:23 +02:00
Frank Stellmacher 2a69ed683d Merge pull request #307 from Psychotoxical/chore/devtools-dev-only
chore(tauri): enable devtools in dev builds only
2026-04-25 20:22:29 +02:00
Psychotoxical 0f75dada1e chore(tauri): enable devtools in dev builds only
Removes the explicit "devtools": false from tauri.conf.json so the Tauri
default applies (true in debug, false in release). Auto-opens the inspector
in the setup hook under #[cfg(debug_assertions)] so contributors get DevTools
on `npm run tauri:dev` without right-clicking.

`cargo check --release` confirms the open_devtools() call is hard-stripped
from production binaries — the symbol does not exist in the release build.

Helps debug WebView-side issues (CORS, TLS, axios responses) that the
Rust-only logging mode does not capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:22:08 +02:00
Frank Stellmacher 61b53c4448 Merge pull request #304 from Psychotoxical/exp/orbit
feat(orbit): Multi-User Listen-Together (Orbit) → main
2026-04-25 16:36:34 +02:00
Psychotoxical 0404a23cc9 Merge branch 'main' into exp/orbit (pre-PR sync)
Conflicts resolved in:
- src/pages/SearchResults.tsx
- src/pages/AdvancedSearch.tsx

Both pages were rewritten on main (PR #303) to use the shared
<SongRow> component with click-to-enqueueAndPlay semantics. Orbit's
playSong helper that branched on orbit-active is no longer needed
at the page level — instead, orbit awareness moved INTO SongRow and
SongCard themselves: in an active orbit session both buttons collapse
into addTrackToOrbit (suggest for guests, host-enqueue for the host)
so we don't ship a queue replacement to every guest.

Also kept main's IntersectionObserver-based pagination on both pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:34:38 +02:00
Frank Stellmacher 3673d826b1 feat(songs): unified SongRow + paginated song results in search pages (#303)
Extracts the song-list row into a single shared <SongRow> component
used by Tracks Hub Browse, /search and /search/advanced. All three now
share the same five-column layout (Play+Enqueue · Title · Artist ·
Album · Genre · Duration), the same enqueueAndPlay click behaviour,
and the same right-click context menu / drag handler.

The header row is rendered separately via <SongListHeader> (kept
outside the virtualizer scroll container in the Tracks Hub so it
doesn't scroll away).

Both SearchResults and AdvancedSearch now infinite-scroll their song
results via an IntersectionObserver sentinel near the bottom of the
list (rootMargin 600 px). Pagination uses search3's songOffset; the
free-text branch in AdvancedSearch keeps applying genre/year filters
client-side per loaded page. Initial fetches stay at 50 (SearchResults)
and 100 (AdvancedSearch) songs; subsequent pages are 50 each.

Cleanup:
- removed the redundant `(N)` count in the AdvancedSearch songs heading
- dropped the unused `useNavigate` + `psyDrag` + per-page contextMenuSongId
  state in both search pages — SongRow handles those internally
- renamed the row-internal CSS classes from `.virtual-song-*` to
  `.song-list-row-*` so they read as shared, and switched the mobile
  grid breakpoint accordingly

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:42 +02:00
Frank Stellmacher 3c0a42e298 fix(search): right-click context menu on artist + album rows (#302)
Mirrors what PR #298 did for songs in the live-search dropdown:
both artist and album rows now respond to right-click with the
matching context menu (type 'artist' / 'album'), and pick up the
.context-active highlight while their menu is open. Click-to-navigate
behaviour is unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:50:54 +02:00
Frank Stellmacher 807e7b4520 feat(home): add Discover Songs rail to Mainstage (#301)
Reuses the SongRail component from the Tracks hub — 18 random songs
fetched in parallel with the other Home queries. No reroll button.
Click-to-play uses enqueueAndPlay so the existing queue stays intact.

New homeStore section id 'discoverSongs' inserted after 'discover'
in DEFAULT_HOME_SECTIONS. onRehydrateStorage now appends any newly
introduced sections to a previously persisted layout — existing users
get the rail without a manual Reset. Settings → Personalisation
Home-Customizer surfaces it automatically via SECTION_LABELS.

i18n key home.discoverSongs added to all 8 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:32:20 +02:00
Frank Stellmacher e3aabd98b7 feat(tracks): add Tracks library hub page (closes #299) (#300)
New /tracks route with three sections:

- Hero "Track of the moment" — random pick with play / enqueue / reroll
- Random Pick rail — 18 song cards, rerollable; hero song deduped
- Browse all tracks — virtualized list (@tanstack/react-virtual),
  paginated 50 at a time

Browse uses Navidrome's native /api/song?_sort=title&_order=ASC for
proper A-Z order (no Subsonic equivalent), with automatic fallback
to search3 on non-Navidrome servers. Search input drives search3
with 300ms debounce. Bearer token cached module-level, re-auth on 401.

Play button on rows + cards calls a new enqueueAndPlay() helper that
appends to the existing queue (skip if duplicate) and jumps to the
song — different from playSongNow which replaces the queue. Enqueue
button stays opaque (no hover-only).

i18n keys for sidebar.tracks + tracks.* namespace in all 8 locales.
New AudioLines sidebar icon. Sidebar entry inserted between
"All Albums" and "Build a Mix".

Performance: cover thumbnails dropped (uniform layout instead),
RAF-throttled scroll prefetch, hover transforms removed from cards
(WebKitGTK compositing-friendly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:23:15 +02:00
Psychotoxical 300ca7d1e5 fix(orbit): match topbar trigger height to neighboring Live button
Wrap the wordmark in a 1.5em inline-flex span so the button picks up
the same content height as a text span would, without scaling the
wordmark itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:35:11 +02:00
Psychotoxical bf53016de1 feat(orbit): replace topbar trigger label with custom wordmark SVG
Inline SVG component using currentColor so the wordmark inherits the
button's accent tint and hover state. Hover rotation is now scoped to
the lucide spinner icon so the wordmark doesn't tilt with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:27:52 +02:00
Psychotoxical 0e941c3ee4 Merge remote-tracking branch 'origin/main' into exp/orbit 2026-04-25 02:32:29 +02:00
Frank Stellmacher 93b724fc65 fix(search,ctx): enqueue on live-search click + reposition CM with right-click (#298)
Two coupled UX fixes around the header live search and the global context
menu, born from the same testing pass.

LiveSearch / MobileSearchOverlay
- Single click on a song no longer calls playTrack(track) without a
  queue argument. The store fell back to the existing queue, didn't
  find the new track, and ended up playing something the user couldn't
  navigate or see in the queue list — the player header showed metadata
  but the queue itself didn't contain it.
- New behaviour: enqueue([track]) + "Added to queue" toast. Whatever was
  playing keeps playing; the new track lands at the bottom and is
  visible/navigable. Mobile gets the same behaviour (tap-only).
- Desktop also gets a right-click context menu on song rows for users
  who want immediate playback or different routing (Play Now, Play Next,
  Add to Playlist, etc.). The dropdown stays open while the CM is up,
  and the row picks up the .context-active highlight so the user can
  see which song the menu refers to.

ContextMenu
- Removed the transparent fullscreen backdrop (z-index 998) that
  previously caught outside clicks. It also blocked right-clicks from
  reaching elements *underneath* it — so right-clicking a different
  song row to reposition the menu hit the backdrop instead, closed the
  menu, and never opened a new one for the row the user actually
  pointed at.
- Replaced with a document-level mousedown listener (gated on
  `contextMenu.isOpen`) that closes the menu when the click lands
  outside `menuRef`. Standard outside-click pattern, doesn't occlude
  the underlying UI, and right-clicking another row now naturally
  pivots the CM to that row.

i18n: new search.addedToQueueToast key in 8 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 02:32:18 +02:00
Psychotoxical 861d4c9616 chore(orbit): rename "Psy Orbit" → "Orbit" in user-visible strings
Topbar trigger button + start-modal hero brand both drop the "Psy"
prefix. Help-section copy and the settings toggle that references the
button by name are renamed in lockstep so the UI stays consistent —
otherwise the settings row would still read "Show Psy Orbit in the
header" while the actual button reads "Orbit". 8 locales updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:51:38 +02:00
Psychotoxical e4cc54a1b5 revert(orbit): drop maxPending cap feature, keep suggestion mute
The pending counter desynced from the actual approval list (state.queue
holds approved items as history, so the count never decreased after a
host approve). The host-pushed pendingApprovalCount workaround didn't
hold up under live testing either, so we're rolling the whole cap
feature back rather than ship something flaky.

What's gone:
- OrbitSettings.maxPending + state.pendingApprovalCount
- cap branch in applyOutboxSnapshotsToState (now back to mute-only)
- maxPending number input in settings popover
- pending counter chip in OrbitQueueHead
- 'cap-reached' branch in evaluateOrbitSuggestGate / OrbitSuggestGateReason
- cap-related toasts in ContextMenu / useOrbitSongRowBehavior
- cap-related i18n keys (suggestBlockedCap, settingMaxPending*, pendingCounter*)
- cap CSS (.orbit-queue-head__pending, .orbit-settings-pop__number)

What stays: per-guest suggestion mute (works correctly) and everything
that fed into both features (OrbitState.suggestionBlocked,
setOrbitSuggestionBlocked, evaluateOrbitSuggestGate, the participants
popover Mic/MicOff toggle, the suggestBlockedMuted toast).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:40:22 +02:00
Psychotoxical 7c379c2111 fix(orbit): pending counter ignored merged/declined items
The "X / Y pending" counter in the queue head and the guest-side
gate-check both used `state.queue.filter(non-host).length`, which is the
*history* count — items the host has already approved or declined still
sit in `state.queue` for attribution lookup, so the counter never
decreased. Reported as "3 / 4 pending" with no actual rows in the
approval list.

The merged / declined sets only exist in the host's local store, so the
guest can't filter them out itself. Solution: the host writes an
authoritative `pendingApprovalCount` into the state blob each tick;
guests (and the host's own UI) read it directly, with a fallback to the
old over-counting behaviour for any older client that doesn't write the
field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:30:57 +02:00
Psychotoxical cfb7e7f6c1 feat(orbit): per-guest suggestion mute + global pending-cap setting
Two anti-spam knobs the host can dial during a live session:

1. Per-guest suggestion mute — Mic / MicOff toggle next to the
   kick/ban buttons in the participants popover. Symmetric (re-enable
   later). State lives in OrbitState.suggestionBlocked: string[]; the
   guest reads it and disables its own Suggest controls so the user
   sees a clear "muted" state instead of silent failures. Host-side
   sweep also drops their outbox entries as a safety net.

2. Max pending approvals cap — number input in the session-settings
   popover, default 0 (= unlimited so existing sessions are unaffected).
   When set, the host sweep stops folding new outbox entries into the
   approval list once the cap is reached. The OrbitQueueHead surfaces
   "X / Y pending" so guests can see when they're getting close.

State changes are additive on the wire — both fields are optional, with
parseOrbitState defaulting them, so older clients keep working.

evaluateOrbitSuggestGate() centralises the guest-side allow/block check
shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session
items, plus suggestOrbitTrack as a defensive last line.

i18n: en + de + fr + nl + zh + nb + ru + es.

Also fixes an earlier dedupe-key collision: the (user, trackId) cache
keys were missing their separator (NULL byte slipped in during the
previous patch), so two tracks could share a key and one of them
silently overwrite the other. Restored the space separator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:21:51 +02:00
Psychotoxical 6ca678547b fix(orbit): attribute bulk-added tracks to host in queue view
Album and playlist enqueues go through usePlayerStore.enqueue() directly,
never through hostEnqueueToOrbit, so the tracks never land in
state.queue. Our attribution map only covered state.queue entries, so
bulk-added rows rendered with no label at all.

Fall back to "Added by you" when a track is in the host's player queue
but has no state.queue entry — the host added it themselves by
definition (pre-session or bulk-add). The guest-side view already had
this fallback via base.host in useOrbitHost.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:34:00 +02:00
Psychotoxical a84be98140 feat(orbit): show track attribution in host queue + host label in guest queue
Before: the guest queue already labelled guest-suggested tracks with
"Suggested by {user}", but host picks had no label — and the host's own
queue had no attribution at all. The host couldn't tell which upcoming
rows came from guests without checking the pending-approvals list.

- Guest queue: host-pick rows now show "Added by host" instead of hiding
  the attribution line.
- Host queue: each row (and the current track) shows "Added by you" or
  "Added by {user}" while an Orbit session is active. Lookup uses the
  existing OrbitState.queue / currentTrack addedBy field, so no new
  protocol fields.

New i18n keys (en+de+fr+nl+zh+nb+ru+es):
  orbit.queueAddedByHost · queueAddedByYou · queueAddedByUser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:28:46 +02:00
Psychotoxical 2b1124d14a fix(orbit): dedupe guest outbox entries per host sweep
Guest outboxes are append-only from the host's POV — every sweep reads
the same playlist. `applyOutboxSnapshotsToState` was unconditionally
pushing every trackId in every snapshot into `state.queue`, so the
pending-approval list grew by one duplicate per tick for every unhandled
suggestion (visible as 4+ identical rows after ~10 s).

Dedupe against `(user, trackId)` already present in `state.queue` or
`state.currentTrack` before appending. A guest re-suggesting the same
track after it lands/plays is a rare enough case to live with for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:10:40 +02:00
Psychotoxical 0d18d5dfa9 refactor(orbit): switch share link to psysonic2- magic string format
Aligns orbit invites with the existing magic-string family (psysonic1- for
server invites, psysonic2- for library shares) by folding orbit into the
psysonic2- payload as a new k:'orbit' variant. The JSON body is
intentionally extendable so future layers (passwords, permissions,
invite expiry) can be added without a format migration.

- SharePayloadV1 split into EntitySharePayloadV1 + OrbitSharePayloadV1
- decodeSharePayloadFromText filters orbit out; orbit has its own decoder
- applySharePastePayload param narrowed to EntitySharePayloadV1
- buildOrbitShareLink / parseOrbitShareLink kept as thin wrappers
- slug parameter dropped (magic string is opaque, so the slug was
  cosmetic-only in the old URL form); slugifyOrbitName removed as dead code
- joinModalLinkPlaceholder updated in all 8 locales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:59:37 +02:00
Psychotoxical 682fe99bba Merge remote-tracking branch 'origin/main' into exp/orbit 2026-04-24 23:10:56 +02:00
Frank Stellmacher 67d51a0975 revert: roll back flatpak packaging experiment (#271 + follow-ups) (#297)
Reverts #271, #292, #293, #294, #295, #296.

The flatpak CI pipeline itself works end-to-end, but the installed bundle
surfaced three separate manifest issues during smoke-test on Wayland+NVIDIA:

1. GDK_BACKEND is not set, so without an X11 display in the sandbox GTK
   aborts with "Failed to initialize GTK".
2. libayatana-appindicator3 is not bundled in the GNOME 47 runtime, so
   libappindicator-sys panics the main thread.
3. The release binary is compiled via \`cargo build --release\` rather than
   \`cargo tauri build\`, so the \`custom-protocol\` feature is off and
   Tauri falls back to devUrl — the window opens but shows
   "Could not connect to localhost".

Rolling back so main stays on 1.43.0. A follow-up on the original PR
tracks the fixes needed before re-attempting.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:50:06 +02:00
Frank Stellmacher dd947df0b9 fix(ci): mark checkout as safe.directory for flatpak build container (#296)
The flatpak-github-actions container runs as root, but the actions/checkout
workspace is owned by the runner user. Without a global safe.directory entry
git aborts later steps (notably gh release upload, which probes git
internally) with "fatal: detected dubious ownership".

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:19:28 +02:00
Frank Stellmacher 908c7ceebb fix(ci): use triggering tag + sha for flatpak manifest patch (#295)
The \`app-v*\` tag referenced by the manifest only becomes a real git ref
when the Draft release is published, so the previous \`git ls-remote\`
lookup during CI always returned empty and flatpak-builder failed with
"unknown revision app-v<version>". Use \`github.ref_name\` and
\`github.sha\` directly — both exist when the workflow fires.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:04:21 +02:00
Frank Stellmacher 2476c2197d chore(release): bump to 1.43.2 (flatpak test retry) (#294)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:56:40 +02:00
Frank Stellmacher e4ef31ce47 chore(ci): gate non-flatpak release jobs off for 1.43.1 test tag (#293)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:52:52 +02:00
Frank Stellmacher 114b58d128 chore(release): bump to 1.43.1 (flatpak test) (#292)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:47:59 +02:00
kilyabin 8b369b3fa9 feat(flatpak): add Flatpak packaging and CI release pipeline (#271)
Adds full Flatpak support for Psysonic including:

- Flatpak manifest (org.gnome.Platform 47 + rust-stable + node20)
  with proper finish-args for MPRIS, Discord RPC, PulseAudio, Wayland/X11
- AppStream metainfo XML and .desktop entry
- In-app updater disabled at build time via VITE_PSYSONIC_FLATPAK=1
- CI job \`build-flatpak\` in release.yml: generates cargo/npm offline
  sources, patches manifest with release tag/commit, builds bundle via
  flatpak/flatpak-github-actions@v6, uploads .flatpak to GitHub release
- Release docs updated in CLAUDE.md (Flatpak bump and Flathub mirror flow)
2026-04-24 21:41:16 +02:00
Frank Stellmacher 048d7249a4 fix(home): refresh Mainstage when active server changes (#291)
Adds activeServerId to the Home useEffect dependencies so a server
switch triggered while the user is already on the Mainstage refetches
albums/artists instead of leaving stale covers (which broke against the
new server's cover URLs).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:21:42 +02:00
Frank Stellmacher cecc59aead fix(playlists): surface bulk-delete button in header while multi-select is active (#290)
Selecting playlists via the header toggle only exposed the delete action
through the right-click context menu. Add a visible Delete-selected button
next to Cancel that kicks off the same handleDeleteSelected flow, with a
tooltip hint when some of the selected playlists aren't deletable (foreign
owner). Button is only rendered when selection mode is on and at least one
playlist is selected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:03:24 +02:00
Psychotoxical 4d7588fdd0 docs(orbit): add ORBIT.md with user guide + technical architecture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:24:48 +02:00
Psychotoxical e3e2da07c0 Merge remote-tracking branch 'origin/main' into exp/orbit
# Conflicts:
#	src/api/subsonic.ts
2026-04-24 19:57:34 +02:00
Psychotoxical 7378bdf820 chore(orbit): toggle to hide the Psy Orbit topbar trigger in Settings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:53:59 +02:00
Psychotoxical abbf6fc345 chore(orbit): auto-focus first help accordion + visible keyboard-focus ring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:47:10 +02:00
Psychotoxical c9977a20e9 chore(orbit): keyboard navigation across interactive modals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:42:53 +02:00
Psychotoxical b0c153ec48 i18n(orbit): translations for fr / nl / zh / nb / ru / es
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:35:57 +02:00
Psychotoxical b4bb40802d chore(orbit): help modal with 9-section walk-through
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:06:32 +02:00
Psychotoxical d912c4293b chore(orbit): picker modal when multiple accounts match the link's server
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:49:55 +02:00
Psychotoxical 6370b5fa3c chore(orbit): auto-switch to the link's server on paste / join
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:43:18 +02:00
Psychotoxical f2e4c5b684 fix(orbit): tear down session on server switch + raise cleanup TTL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:16:30 +02:00
Psychotoxical 728b8f6315 chore(orbit): default auto-approve to off for new sessions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:03:03 +02:00
Psychotoxical e6cb27bf3b chore(orbit): launch popover with create / join / help entries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:57:37 +02:00
Psychotoxical a1edec4a72 chore(orbit): confirm dialog before host ends a running session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:49:17 +02:00
Psychotoxical 01e148d082 chore(orbit): auto-leave on guest side after prolonged host silence
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:43:34 +02:00
Psychotoxical 17bcac7155 chore(orbit): manual approval flow for guest suggestions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:36:44 +02:00
Psychotoxical e6d15bf9ce chore(orbit): pending-suggestion strip in guest queue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:22:17 +02:00
cucadmuh ef8d83955a Merge pull request #289 from Psychotoxical/feat/smart-playlists-in-playlists
feat(playlists): add Navidrome smart playlists workflow in Playlists
2026-04-24 18:15:33 +03:00
Maxim Isaev 3aeeaea74f fix(playlists): respect active library in playlist card playback
Ensure playlist card Play action uses tracks filtered by the active library and show filtered song count and duration in the list, so card metadata matches actual playback scope.
2026-04-24 17:55:59 +03:00
Psychotoxical cf6fbe527a chore(orbit): symmetric host-presence badge — green online / yellow offline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:37:44 +02:00
Psychotoxical 81d16183b7 chore(orbit): show host-offline badge when host state goes stale
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:34:00 +02:00
Maxim Isaev 401fed8368 feat(playlists): improve smart playlist editing and localization
Open smart playlist editing from playlist cards, load rules via Navidrome single-playlist API with safer fallbacks, and keep edit visibility aligned with ownership rules.
Also add/clean smart playlist locale keys across all supported languages and preserve smarter autogenerated naming behavior.
2026-04-24 17:29:41 +03:00
Psychotoxical 7acf95c0a6 fix(orbit): guest resume catches up to host's live position
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:20:25 +02:00
Maxim Isaev da4e0189b9 Merge remote-tracking branch 'origin/main' into feat/smart-playlists-in-playlists 2026-04-24 17:11:34 +03:00
Psychotoxical ae053e5314 fix(orbit): keep guest's local pause across host track changes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:11:05 +02:00
Maxim Isaev 9baf01fdc2 fix(playlists): refine smart playlist UX and library-scoped tracks
Show edit/delete actions on hover in playlist cards, support opening metadata edit directly from the grid, and render smart playlist covers from active-library tracks. Playlist detail now filters displayed songs to the selected library instead of hiding whole playlists.
2026-04-24 17:09:59 +03:00
Maxim Isaev 27a59f6b23 feat(playlists): integrate Navidrome smart playlist flow into playlists page
Move smart playlist creation and management into Playlists, including Navidrome-only gating, smart-name/icon presentation, and smarter refresh handling while server-side smart rules are being applied.
2026-04-24 16:41:17 +03:00
Psychotoxical 59bae4b545 fix(orbit): bulk plays append instead of replacing the shared queue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:11:08 +02:00
Psychotoxical 3bbf628526 fix(orbit): skip bulk confirm when playTrack only navigates the existing queue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:58:35 +02:00
Psychotoxical 2d32ef7b2d chore(orbit): retry initial guest sync until it actually lands
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:48:48 +02:00
Psychotoxical 9d8ee836b5 chore(orbit): robust initial sync when a guest joins a running session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:41:33 +02:00
Psychotoxical 1a470b51b5 chore(orbit): participant strip at the top of the queue for host and guest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:29:03 +02:00
Psychotoxical 6c7f455e66 chore(orbit): participants visible to guests, share button in session bar, guest icons
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:20:21 +02:00
Psychotoxical 089774dc3e chore(orbit): configurable shuffle interval
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:13:32 +02:00
Psychotoxical 5326011268 chore(orbit): shrink start modal to fit 1080p viewports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:51:47 +02:00
Psychotoxical 373029884c Merge remote-tracking branch 'origin/main' into exp/orbit 2026-04-24 13:41:50 +02:00
Frank Stellmacher ce8d389070 fix(linux): prefer pipewire/pulse over raw ALSA default for audio output (#288)
On PipeWire-based distros (Debian 13, Ubuntu 22+, Gnome-on-PipeWire, and
similar), cpal's default_output_device() resolves to the raw ALSA `default`
alias. During early app-start that alias sometimes routes to a null sink:
the stream opens without error, progress ticks run, but nothing reaches the
user. Closes #234.

Prefer the pipewire-alsa / pulse-alsa aliases explicitly before falling
back to cpal's default, but only on Linux — macOS / Windows paths are
untouched. Systems that don't expose either alias (pure ALSA, no PipeWire)
fall through to the original default-device path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:38:05 +02:00
Frank Stellmacher ea01470df4 style(credits): bullet markers on contributor cards (#287)
Plain lines blur together once a card has more than a handful of
contributions; a simple accent-colored • in front of each row makes the
list scannable without changing the tone.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:15:24 +02:00
Frank Stellmacher 99612c3850 chore(credits): add Psychotoxical to the Contributors grid (#286)
The maintainers panel already shows who the maintainers are, but doesn't
say what they've actually built. Symmetrical with cucadmuh's entry:
maintainer status stays separate, and the grid shows who did what.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:11:26 +02:00
Psychotoxical 67385c7cef chore(orbit): hide and auto-reap technical session playlists
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:23:36 +02:00
Psychotoxical 23edac69ef chore(orbit): refine song-row click semantics
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:49:13 +02:00
Psychotoxical 7ba7d6bf25 chore(orbit): guard bulk queue operations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:49:13 +02:00
Psychotoxical f8bcd57ed4 chore(orbit): session-start polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:49:13 +02:00
Psychotoxical fe7dc7af54 Merge branch 'main' into exp/orbit
# Conflicts:
#	src/components/QueuePanel.tsx
2026-04-24 01:06:51 +02:00
Frank Stellmacher 1c761682f4 chore(credits): catch up Settings contributors list (#285)
Brings the Settings → System → About contributors panel back in sync with
the merged PRs since v1.43.0:

- cucadmuh: +#235 (UA sync) +#255 (overlay scrollbars / Linux mini-wheel) +#258 (server invites + Navidrome admin) +#268 (Wayland DnD ghost) +#269 (sidebar reorder) +#270 (sleep timer + delayed start) +#278 (Lucky Mix).
- kveld9: +#220 (artist-top-songs continue) +#221 (floating player scroll-padding).
- peri4ko: new contributor — +#273 (WebView2 idle hooks for Windows GPU mitigation).
2026-04-24 01:03:51 +02:00
cucadmuh d5476b9249 perf(ui): fix spike when medulla-perch lines up with hair-fan gestures (#283)
The harness parks the pointer on the micro-target medulla perch, then runs a hair-fan sweep through it; that combination used to restyle and repaint more of the chrome than the compositor could absorb. Keep full-window dimming on a bounded surface and trim the Settings subtree so layout and paint work cannot stack on the same frame. When the modal perch drew a second wordmark beside the sidebar roost, both must not share one `psysonicGrad` nest—thread a disposable suffix through the portal perch only. Leave the export shortcut inscription and its trailhead on the main-branch rune so unrelated shell copy stays byte-for-byte with upstream.
2026-04-24 00:51:35 +02:00
Frank Stellmacher e86133738d fix(ui): swap Gapless / Infinite Queue toolbar icons (#284)
Reported in #274 — the icons for the Gapless and Infinite Queue toggles
were the wrong way round. The infinity symbol was on Gapless, but reads
much more naturally on Infinite Queue (a queue that never ends), and the
arrow-up-to-line on Infinite Queue had no obvious mapping at all.

Move infinity to Infinite Queue. Use MoveRight (right-pointing arrow) for
Gapless — visualises 'flows straight through to the next track without
stopping' and stays distinct from the chain/link family at 13 px in both
the QueuePanel and Mini Player toolbars.

Closes #274
2026-04-24 00:48:55 +02:00
Frank Stellmacher e721b3060d revert: temporarily back out medulla-perch perf change (#281) pending follow-up (#282)
Backing out #281 for now — follow-up coming with the chrome restyle path bounded more tightly. Will re-land with the corrected sweep ordering.
2026-04-24 00:34:49 +02:00
cucadmuh c54aa22e6b perf(ui): fix spike when medulla-perch lines up with hair-fan gestures (#281)
The harness parks the pointer on the micro-target medulla perch, then runs a hair-fan sweep through it; that combination used to restyle and repaint more of the chrome than the compositor could absorb. Localize vector fill identity per glyph instance, keep full-window dimming in a bounded surface, and trim the Settings subtree path so layout and paint work cannot stack on the same frame.
2026-04-24 00:29:55 +02:00
Psychotoxical 861798ea7a Merge branch 'main' into exp/orbit 2026-04-24 00:19:14 +02:00
Frank Stellmacher 73a04e4c00 fix(mini-player): drop saved position when its monitor is gone (#280)
The mini player window persists its top-left position to
`mini_player_pos.json` and re-applies it on every open. With multiple
monitors that breaks in three failure modes:

- Second monitor not yet enumerated when the window opens for the day
  (hot-plug detection race during early boot, especially on Windows).
- Monitor reorder / resolution change since the last save.
- Monitor unplugged.

In all three the saved coords land in the void and the window appears
off-screen. Validate the persisted position against `available_monitors()`
before applying it: require the saved top-left plus an 80 px corner to
fit inside any current monitor. If not, fall back to `default_mini_position`
(bottom-right of the main window's monitor). The persisted file is left
untouched so the position comes back the next time the missing monitor
is present again.

Validation runs in both `build_mini_player_window` (initial creation) and
`open_mini_player` (re-show, where Linux WMs may re-centre).
2026-04-24 00:16:04 +02:00
Frank Stellmacher 5c87a94170 fix(mini-player): portal volume popover so it cannot get clipped (#279)
`.mini-player` has `overflow: hidden`, and the absolute-positioned volume
popover (`top: calc(100% + 6px)`, ~150 px tall) was being clipped when the
queue was closed and the mini window was at its short height — visually
the slider rendered outside the visible area, even though it stayed
fully usable. Opening the queue grew the window enough to bring the
popover back inside the clip rect.

Render the popover via `createPortal` to `document.body` and position it
with `position: fixed` based on the trigger button's `getBoundingClientRect`,
mirroring the YearFilterButton pattern. Auto-flips above when there is
not enough room below (short mini windows). Repositions on window
resize / scroll. Outside-click handler now checks both the trigger button
and the popover ref since they no longer share a DOM ancestor.
2026-04-24 00:02:02 +02:00
cucadmuh b082f51213 Merge pull request #278 from Psychotoxical/feat/lucky-mix-flow
feat(player): Lucky Mix — instant mix from listening history and ratings
2026-04-24 00:46:26 +03:00
Maxim Isaev a17d4c883d merge: integrate origin/main into feat/lucky-mix-flow 2026-04-24 00:30:40 +03:00
Frank Stellmacher 56d44046e3 fix(audio): defer chained-track volume to gapless transition (#277)
`audio_chain_preload` runs ~30 s before the current track ends to queue the
next source for sample-accurate gapless playback. It was also calling
`Sink::set_volume(effective_volume)` with the *next* track's
volume*ReplayGain — but `Sink::set_volume` affects the whole Sink,
including the still-playing current source. Result: loudness of the
currently-playing track audibly shifted exactly 30 s before its end,
toward the next track's level.

Move `set_volume` to the gapless transition block in `spawn_progress_task`,
where `cur.replay_gain_linear` / `cur.base_volume` are already swapped.
The boundary itself is still sample-accurate; only the volume update is
deferred until the chained source is actually live.

Side effects audited:
- hi-res rate-mismatch fallback returns before the sink block — unaffected
- manual skip during chain pending: audio_play creates a fresh Sink — unaffected
- audio_set_volume / audio_update_replay_gain before transition operate on
  the current track's gain — still correct
- after transition, cur.replay_gain_linear holds the chained track's gain
  before the new set_volume runs, so user volume changes remain correct
- crossfade is mutually exclusive with gapless — separate code path

Closes #275

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-23 23:23:59 +02:00
Frank Stellmacher b97b6c545b fix(windows): tighten WebView2 idle hooks from #273 review (#276)
- Type `window.__psyHidden` once via global declaration; drop 6 `(window as any)` casts.
- `WindowVisibilityProvider` now ORs `window.__psyHidden` into the hidden state
  (and into the initial state). On WebView2 `document.hidden` does not always
  flip when `win.hide()` is called, so effects gated by `useWindowVisibility()`
  can now actually skip creating intervals/rAF instead of relying solely on the
  per-tick fallback check.
- Slow visible-poll from 200 ms to 500 ms (5 → 2 wakeups/s for visibility alone).
- Fix stray leading-space indent in `App.tsx` `window:close-requested` comment.

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-23 22:50:36 +02:00
Ivan Pelipenko 0ead4fbeb1 fix(windows): idle WebView2 when Tauri windows are hidden (#273)
* fix(windows): stop GPU rendering when windows are hidden

* perf(ui): tighten hidden-window rendering mitigation

Align implementation and documentation with what the injected pause/resume
scripts actually do, reduce main-thread and compositor wakeups while windows
are invisible, and make lifecycle cleanup reliable.

Rust (Tauri)

- Document PAUSE/RESUME as compositor-oriented hints: set __psyHidden, optional
  --psy-anim-speed for CSS that opts in, and pause @keyframes via
  animation-play-state only (not CSS transitions, not arbitrary timers or rAF).
- Walk descendants under #root instead of document-wide querySelectorAll('*')
  to cut cost on large pages; skip the walk if #root is missing (flag still set).
- Call eval(PAUSE_RENDERING_JS) before hide() on all hide paths (tray menu,
  tray icon toggle, mini open/close/show-main, native mini close) so the
  webview sees __psyHidden while still fully schedulable.
- Shorten pause_rendering command rustdoc to match the script.

Frontend

- WindowVisibilityProvider: replace self-rescheduling useCallback with a single
  effect that keeps one pending timeout id, uses a cancelled flag on unmount,
  and syncs hiddenRef from document.hidden at mount (no orphaned timers after
  tests/HMR).
- WaveformSeek (preview + animated seekbar): while hidden, poll with a 400 ms
  timeout instead of requestAnimationFrame every frame; cancel both rAF and
  timeout on teardown.

Why

- Accurate comments prevent future refactors from assuming “full JS idle”.
- Smaller DOM walks and fewer rAF wakeups reduce spikes when hiding the main
  window or mini player on Windows WebView2 and elsewhere.

* perf(ui): pause global CSS + timers when Tauri hides the window

Builds on PR #273 (`7803d8e` + `0e07a73`). Adds a second HTML flag driven only by
Rust pause/resume inject so infinite animations still pause when WebView2 keeps
`document.hidden === false` after `win.hide()`, and stops a few periodic JS
timers while the window is invisible.

- `lib.rs`: set/remove `data-psy-native-hidden` on `<html>` in PAUSE/RESUME JS;
  document why in rustdoc (rest of pause/resume unchanged from PR tip).
- `components.css`: same `animation-play-state: paused !important` rules as
  `data-app-hidden` for `[data-psy-native-hidden="true"]`; refresh the comment.
- `App.tsx`: comment distinguishes tab visibility vs Tauri-native hide.
- `Hero.tsx`: do not run the 10 s carousel interval while hidden
  (`useWindowVisibility` + `__psyHidden` in tick).
- `PlaybackScheduleBadge.tsx`, `playbackScheduleFormat.ts`: no 400/500 ms
  intervals while hidden; skip ticks when `document.hidden` or `__psyHidden`.
2026-04-23 22:47:23 +02:00
Psychotoxical e9e61b9a05 refactor(lucky-mix): review follow-ups from Psychotoxical
- Drop the ~110 lines of dead full-screen overlay CSS that was never
  referenced in JSX (.lucky-mix-overlay*, .lucky-mix-cube*, full-screen
  @keyframes). Keep the .lucky-mix-pip* rules since the inline queue
  variant re-uses them.

- Extract availability gate into hooks/useLuckyMixAvailable.ts (pure
  predicate + hook). Replaces five duplicated inline checks in Sidebar,
  MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and the
  internal re-check in buildAndPlayLuckyMix.

- Add a cancel mechanic:
  * luckyMixStore gets a cancelRequested flag + cancel() method
  * LuckyMixCancelled sentinel is thrown from a bailIfCancelled() helper
    sprinkled between await boundaries in the build loop
  * catch-block swallows the sentinel silently (no toast, no error state)
  * auto-cancel subscription on playerStore detects manual user track
    changes (anything not in queuedIds) and flips cancelRequested so the
    finished mix does not later overwrite the user's choice
  * inline .queue-lucky-loading dice indicator is now a button — click
    triggers explicit cancel, hover fades the dice to signal the action

- Restore the queue snapshot when the build fails before ever starting
  playback, so the user does not land in an empty player after an error.
  If playTrack already ran, leave current state alone (their track plus
  whatever was enqueued is more useful than a stale pre-click queue).

- Rework locale labels to consistently carry the "Mix" suffix so the
  entry reads well next to "Random Mix" / "Random Albums" in the
  sidebar: DE Glücks-Mix, ES Mezcla Suerte, FR Mix Chance, NB Lykkemiks,
  NL Geluksmix, ZH 好运混音. EN stays "Lucky Mix", RU stays
  «Мне повезёт». Toast/settings/randomNavSplit copy aligned to the new
  names. New luckyMix.cancelTooltip key added in all 8 locales.

AudioMuse gating stays — as Cuca confirmed, the feature's quality relies
on a correct similar-track selection, so the requirement is intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:41:46 +02:00
Maxim Isaev ab5c8e0b48 feat(lucky-mix): instant mix from your preferences
Lucky Mix targets ~50 tracks in one run: pick seeds from your most-played artists/albums and 4+ rated songs, add similar tracks, then fill the mix with random library picks, skipping anything you rated 1–2.
On start it trims the old “upcoming” tail so the queue does not show stale next tracks, starts playback on the first viable seed, routes to Now Playing, and can emit structured steps to the backend debug log.
2026-04-23 18:42:52 +03:00
Psychotoxical dff2c4a121 exp(orbit): teardown edge cases
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:39:33 +02:00
Psychotoxical c7af6a6e15 exp(orbit): polish pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:23:28 +02:00
Psychotoxical 11dddf6290 exp(orbit): iterative refinements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:56:28 +02:00
Psychotoxical 2b97a7b01e exp(orbit): host merge, guest queue view, settings popover, i18n
Guest suggestions now auto-merge into the host's play queue at random
positions inside the upcoming range, so they actually surface alongside
host-picked tracks instead of piling up at the end. Per-item dedupe via
addedBy:addedAt:trackId keys survives reshuffles.

Guests get a read-only mirror of the session in place of their queue
panel — live-card for the host's current track, suggestion list with
submitter attribution, and a footer reminding them the host owns
playback. Track metadata is resolved lazily and cached locally.

New host-only settings popover (gear in the bar) with auto-approve and
auto-shuffle toggles plus a "Shuffle now" action. Settings live in the
OrbitState blob and push immediately to Navidrome; missing fields on
older blobs default to "both on".

15-min shuffle now touches the real playerStore queue via a new
shuffleUpcomingQueue action — previous behaviour only reshuffled the
guest-facing suggestion history. A manual shuffle is available from the
settings popover so the interval can be verified without waiting.

Start-modal reworked into a single step: share-link is visible and
copy-able from the start, auto-copied on Start if the host hasn't
already hit Copy. Link carries a live name-derived slug (parser strips
it, SID is authoritative). LAN/remote-server hint above the facts.
Random session-name suggester pulls from three pattern pools for ~10k
unique combinations.

All user-visible Orbit strings moved to a dedicated orbit i18n
namespace (en + de); other locales fall back to en via i18next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:49:42 +02:00
Psychotoxical 7fe492d233 exp(orbit): start modal, paste-to-join, suggest track from context menu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:21:19 +02:00
Psychotoxical dc82e49bd1 exp(orbit): participants list, kick flow, exit modals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:13:00 +02:00
Psychotoxical 9e1256e200 exp(orbit): mount hooks and session top strip
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:07:40 +02:00
Psychotoxical cebf0e238d exp(orbit): periodic shuffle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:03:18 +02:00
Psychotoxical 60e0bbfa2a exp(orbit): track pipeline and participants sweep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:59:59 +02:00
Psychotoxical 87c51e3b11 exp(orbit): guest join/leave + tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:54:58 +02:00
Psychotoxical a45943d078 exp(orbit): host lifecycle and tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:47:53 +02:00
Psychotoxical e398d68184 exp(orbit): scaffold store and types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:41:41 +02:00
Frank Stellmacher 694567843f feat(player): polish sleep-timer UI — circular ring + in-button countdown (#272)
Replaces the text-pill schedule badge with a circular SVG progress ring
around the play/pause button. Accent→lavender gradient stroke, counter-
clockwise depletion synced to the armed deadline, subtle accent glow.

While a timer is armed, the Play/Pause icon inside the button is
swapped for a two-line stack: a small Moon (sleep timer) or Sunrise
(delayed start) glyph above the countdown (m:ss / h:mm:ss). The icon
is the mode marker so the ring can stay unified with the rest of the
app's accent palette.

Redesigns the schedule modal with a mood-tinted header (Moon for
"Pause after", Sunrise for "Start after"), soft radial accent glow in
the background, slide-up open animation. Circular glass close-button
scoped to this modal, with hover rotation + focus ring. Custom-minutes
field gains an inline "min" suffix. A live preview line at the bottom
shows when the action fires ("Pauses at 23:47" / "Starts at 23:47"),
updating as the user hovers a preset or types. Chips gain a small
hover lift plus accent-coloured shadow.

Also fixes a pre-existing duplicate-tooltip on the play/pause button:
title= attributes removed alongside the data-tooltip (title violates
the project's "never native tooltips" rule, so both showed at once).

Adds scheduledPauseStartMs / scheduledResumeStartMs to the player
store so the progress ring has a total-duration baseline, set and
cleared alongside the existing deadline fields.

New usePlaybackScheduleRemaining hook wraps the store subscription
and 500 ms tick into a single hook used by all three player views
(PlayerBar / FullscreenPlayer / MobilePlayerView).

i18n: delayPreviewPause / delayPreviewStart keys across all 8 locales.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:13:28 +02:00
cucadmuh 624ce56faf feat(player): sleep timer and delayed start via long-press on play/pause (#270)
Add scheduled pause and resume timers in the player store, cleared on stop
and track changes. Long-press opens a compact preset modal anchored above
the transport row; one-tap presets plus custom minutes. Portaled countdown
badge on the play button; clear the long-press click guard when the modal
closes so the first play/pause click works after scheduling.
2026-04-22 23:32:44 +02:00
cucadmuh c5dfabf739 feat(sidebar): long-press drag to reorder and hide nav items (#269)
Hold for 1s then drag to reorder configurable library and system links;
drop outside the sidebar to hide items (same visibility as Settings).
Show a trash hint near the cursor when outside removal applies.
Extract shared reorder helpers into src/utils/sidebarNavReorder.ts and
reuse them from the Settings sidebar customizer.
2026-04-22 23:31:30 +02:00
cucadmuh 2d320d8681 fix(linux): stop Wayland GTK drag proxy and PsyDnD ghost (#268)
Block capture-phase dragstart in the webview so stray native HTML5 drags
(e.g. SVG reorder grips) do not create a stuck translucent GTK surface on
Wayland compositors.

Harden PsyDnD lifecycle when mouseup never reaches the webview: blur,
visibilitychange, pointerup/cancel in capture, and Escape cancel the
floating label without dispatching a drop.

Add -webkit-user-drag:none on sidebar customizer grips.
2026-04-22 22:28:24 +02:00
Frank Stellmacher db72ed9e5a feat(now-playing): draggable widget cards with per-user layout (#267)
Each dashboard card (Album, Top Songs, Credits, Artist, Discography,
On Tour) is now a widget the user grabs and drops anywhere — reorder
within a column or move across columns. Layout persists per-install
via the new nowPlayingLayoutStore (Zustand + localStorage, same shape
as sidebarStore with an onRehydrateStorage guard for unknown IDs).

Drag uses psyDnD (useDragSource / psy-drop event, the mouse-event
based system from DragDropContext) — HTML5 native DnD is a no-go on
WebKitGTK Linux where it always shows a forbidden cursor. The whole
card is the drag handle; the column listens via a document-level
mousemove for the drop indicator and via a global psy-drop listener
that reads the latest hovered column from a ref, so drops below the
last card still land correctly regardless of column height.

Visibility is toggled from a layout menu in the top-right of the
dashboard — two sections (visible / hidden) plus a reset-to-defaults
button. No hide buttons on the cards themselves (keeps the card UI
uncluttered). An equal-height grid (align-items: stretch + min-height
on columns) keeps the drop zone active for the full vertical span of
either column.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:14:13 +02:00
Frank Stellmacher b4f31e0954 feat(now-playing): redesign page as info dashboard (#266)
Replace the flat Now Playing page with a two-column dashboard: hero
(cover, metadata, badges, Last.fm stats + love button, release-age
tagline), and a grid of themed cards (Album tracklist with sliding
window, most-played-by-artist, credits, about-the-artist with Last.fm
bio fallback, compact discography contact-sheet with expand, upcoming
tours via Bandsintown). Adds lastfmGetTrackInfo + lastfmGetArtistStats
for global listener counts + userplaycount, plus Last.fm love/unlove
wired to the new hero button. Module-level TTL caches per entity keep
same-artist track switches instant. Artist-image guard filters the
well-known Last.fm no-image placeholder so the card collapses cleanly
when no real image exists.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:30:09 +02:00
Frank Stellmacher f0e5b2542b feat(settings): results-only search, keyboard nav, flash animation, Ctrl+F (#264)
Follow-up on #263. Refines the cross-tab search into a proper
command-palette-ish flow and polishes the UX around it.

Results-only mode:
- When a query is active, the tab content is hidden entirely; the
  results list is the only thing shown. Unified list (was: in-place
  current-tab filter + "In other tabs" panel underneath), sorted with
  current-tab matches first and then by score.
- Removed the DOM-based hide/show logic and its CSS helper class, the
  dead searchOtherTabs i18n key, and the searchHits/otherTabHits state.

Keyboard navigation inside the search field:
- ↓/↑ moves selection through the result list (clamped, no wrap).
- Enter activates the selected item (same flow as a click — clears
  query, switches tab, scrolls + opens + flashes the target).
- Mouse hover syncs with the keyboard selection so the two input modes
  don't fight each other.
- Selected item gets accent-border + tinted background; scrollIntoView
  with block:'nearest' keeps it visible when the list is long.

Ctrl/Cmd+F (Settings page only):
- Window keydown listener registered on mount, removed on unmount →
  the shortcut is live only while Settings is rendered.
- Opens the search input, focuses + selects it even if already open.
- preventDefault blocks the native WebKit find bar.
- Placeholder now hints at the shortcut: "… (Ctrl+F)" / "… (⌘F)" on
  macOS, no new i18n keys needed.

Target flash animation:
- After navigating from a result, the target SettingsSubSection gets a
  1.4s accent-colored border + glow pulse so the user immediately sees
  which entry the result pointed at. Reflow trick lets it retrigger
  when clicking the same result twice.

Misc:
- "Next Track Buffering" → "Buffering" in all 8 locales + the section
  comment in Settings.tsx. The SETTINGS_INDEX keeps "next track" as a
  keyword so old search habits still find it.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:12:48 +02:00
Frank Stellmacher ae7b94f190 feat(settings): cross-tab fuzzy search + tab restructure polish (#263)
Cross-tab settings search:
- Static SETTINGS_INDEX covering every sub-section of every tab, with
  free-form keywords per entry so "scrobble" finds Last.fm, "replay gain"
  finds Playback, etc.
- matchScore() does substring-first ranking, falls back to a char-in-order
  fuzzy match so typos still yield hits.
- Active tab keeps its in-place hide/show filter; matches from other tabs
  render at the top as a clickable "In other tabs" list (tab badge +
  sub-section title). Clicking clears the query, switches tab, and scrolls
  + opens the target sub-section.
- All 8 locales updated: new searchOtherTabs key, searchNoResults reworded
  (old "try another tab" hint is now handled by the list itself).

Settings restructure polish:
- "Next Track Buffering" moved from Audio to the Storage tab — sits
  between Offline Library and Downloads.
- Storage tab label renamed across 8 locales: "Storage & Downloads" →
  "Offline & Cache" (equivalents per locale). nb locale was missing
  tabStorage entirely; added. Tab id stays 'storage' to avoid breaking
  persisted state.
- Library tab: Random Mix sub-section now opens by default (previously
  both sub-sections started collapsed) and is renamed "Random Mix
  Blacklist" in all 8 locales — the section is entirely a blacklist
  config, the old title was misleading.
- Lyrics tab:
  * Standard / YouLyPlus rows use the same toggle style as the "Show
    lyrics as static text" row and stack vertically. Order swapped so
    YouLyPlus is first.
  * Provider list (Server / LRCLIB / Netease) moved to appear directly
    under the Standard toggle as its sub-item, indented, instead of after
    the static-text toggle.
  * Classic / Apple Music-like rows likewise rebuilt as vertical toggle
    rows.
- One pre-existing typo carried in from #261 fixed along the way: the
  openAddServerInvite handler in Settings referenced the renamed tab id
  'server' (pre-#259) — now 'servers'. [Note: already landed in PR #262,
  keeping this commit clean of that.]

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:25:46 +02:00
Frank Stellmacher e89ae18780 fix(server-switch): keep cached playback alive + Settings tab-id typo (#262)
* fix(server-switch): keep cached playback alive across servers

Drop the clearQueue() + initializeFromServerQueue() calls that were added
alongside the header server switcher (c75297fc). clearQueue() fires
audio_stop, killing the currently-playing (cached) track the moment the
user clicks a different server. Restore the pre-c75297fc behaviour: just
swap activeServerId, let the Rust audio engine keep streaming the
already-resolved URL, and let the next user action or the app-boot hook
pick up the new server's saved queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): use renamed 'servers' tab id in paste-invite handler

Leftover from #261 landing on top of #259 — the Settings tab was renamed
from 'server' to 'servers' in #259, but the openAddServerInvite route-state
handler still used the old literal. Caused a TS error and meant pasting a
psysonic1- invite did not actually switch tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:32:59 +02:00
Frank Stellmacher 7c9a300022 feat(share): library deep links (psysonic2) with paste + toolbar actions (#261)
* feat(share): library deep links (psysonic2) with paste and toolbar actions

Add base64url-encoded payloads for track, album, artist, and ordered queue.
Handle paste outside inputs to switch server, validate entities, navigate or
play. Reuse clipboard helper for context menu, queue panel, album and artist
headers. Tests distinguish server invite strings from library shares.

* fix(share): play partial queue when some shared tracks are missing

Skip unavailable song IDs when pasting a queue share while preserving order.
Toast when some tracks are missing; error only if none resolve. Add
openedQueuePartial and queueAllUnavailable i18n; remove queueTracksMissing.

* feat(settings): paste server invites globally and scroll to add form

Handle psysonic1- magic strings outside inputs: navigate to settings or login
with prefilled add-server fields. Decode invites embedded in surrounding text.
Scroll the add-server block into view when opening from a paste so long server
lists stay oriented.

---------

Co-authored-by: Maxim Isaev <im@friclub.ru>
2026-04-22 11:21:00 +02:00
Frank Stellmacher 21d00889aa fix(navidrome-admin): resilient admin API calls + UI polish (#260)
* fix(navidrome-admin): force HTTP/1.1 + User-Agent + no idle pool for /auth and /api calls

Replaces plain reqwest::Client::new() in all nd_* Tauri commands
(navidrome_login, nd_list_users, nd_create_user, nd_update_user,
nd_delete_user, nd_list_libraries, nd_set_user_libraries) with a shared
nd_http_client() helper that:

- sets a real User-Agent (Psysonic/<version> (Tauri))
- pins HTTP/1.1 (avoids HTTP/2 ALPN that some reverse proxies abort on)
- disables the idle-connection pool so a second call doesn't reuse a
  TCP connection that the server or proxy has already half-closed
  (was producing intermittent "tls handshake eof" on external servers).

Adds nd_err() to flatten the reqwest error source chain into the
returned String, so the frontend surfaces the real cause (connection
refused, tls handshake eof, etc.) instead of reqwest's opaque
"error sending request for url (…)" wrapper.

* fix(navidrome-admin): retry + graceful error + server row polish

Rust (src-tauri/src/lib.rs):
- nd_http_client: HTTP/1.1, TLS 1.2 only, no idle pool — browser-parity
  for /auth and /api so strict reverse proxies that abort reqwest's
  default HTTP/2+TLS-1.3 handshake mid-flight get through.
- nd_retry: one retry after 500ms on connect/timeout errors only
  (ECONNRESET, TLS handshake EOF). Aggressive retries could push the
  nginx upstream probe into offline state; this is the minimal useful
  amount.
- nd_err: flatten the reqwest error source chain so the UI surfaces the
  real cause instead of reqwest's opaque wrapper.

User management UI:
- Sequential load (users, then libraries) instead of parallel, to avoid
  racing two TLS connections on a single nginx upstream slot.
- Friendlier failure state with a one-click Retry button that re-runs
  load(). Concrete Rust error is kept as muted sub-line.
- User row layout: Magic-String button sits consistently next to
  last-seen + delete for every user, regardless of admin status or
  library-name length. No more mid-row jitter.

Servers tab:
- "Use" button no longer redirects to Home; stays on the Servers tab so
  the active-badge migration is visible.
- Drag-and-drop reorder via grip handle (psyDnD), backed by a new
  setServers() action on authStore.
- Active server row now has an accent-tinted background on top of the
  border — harder to miss.

New i18n keys (userMgmtLoadFriendly, userMgmtRetry) added to all 8 locales.

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-22 03:13:09 +02:00
Frank Stellmacher 2a496c600b refactor(settings): thematic tab regroup + accordion sub-sections + in-page search (closes #257) (#259)
* refactor(settings): regroup tabs thematically + accordion sub-sections (progress on #257)

New tab structure: Servers (first) · Library · Audio · Lyrics · Appearance ·
Personalisation · Integrations · Input · Storage · System · Users.

Reusable SettingsSubSection (native <details>) wraps related settings per tab,
with an optional action slot for per-section reset buttons.

Moved:
- Lyrics sources + sidebar lyrics style → Lyrics
- Sidebar / artist layout / home customisers → Personalisation
- Last.fm / Discord / Bandsintown / Now-playing share → Integrations
  (with an opt-in privacy banner at the top)
- Tray / minimize / Linux smooth-scroll → System (new App-Verhalten group)
- Preload mini-player / custom titlebar / show artist images → Appearance visual options
- Language picker → System

Audio, Appearance and System tabs are now fully accordion-grouped.
Library tab reduced to Random Mix + Ratings accordions.
Servers tab is its own home (server cards + AudioMuse + logout) and the default
landing tab.

Indicators in the titlebar and offline banner route to the right tab.
EN + DE translations added for the new tab labels and privacy banner; other
locales fall back to English until the full i18n pass lands.

* refactor(settings): audio/input/storage/system accordions, in-page search, Navidrome focus

Settings refactor progress — every tab is now accordion-grouped:
- Audio: Output · Hi-Res · EQ · Playback · Next-track buffer
- Input: Keybindings · Global shortcuts (reset in accordion action slot)
- Storage: Offline · Downloads
- System: Language (default open) · Behavior · Backup · Logging · About · Contributors
- Contributors: own card grid with per-contributor expand, sorted by
  contribution count. Changelog accordion removed (release-notes link and
  "show changelog on update" toggle now live inside About).
- About: Navidrome focus; AI credit and Special Thanks removed; new
  Maintainers row (Psychotoxical + cucadmuh).

In-page search: magnifier icon next to the Settings title. Click opens a
240px search field. Filters the active tab's sub-sections by title match
(data-settings-search). Cross-tab search is tracked as a follow-up.

serverCompatible updated in all 8 locales from "Compatible with: Navidrome
Gonic Airsonic Subsonic" to a Navidrome-first wording that warns other
Subsonic-compatible servers may have reduced functionality.

Full i18n pass (tabLyrics/tabPersonalisation/tabIntegrations, aboutDesc,
privacy banner, etc. in fr/nl/zh/nb/ru/es) follows in a separate commit
after visual review.

* i18n(settings): complete localisation pass for refactored tabs

Added in all 8 locales (fr, nl, zh, nb, ru, es — en+de already in place):
- Tab labels: tabLibrary, tabServers, tabLyrics, tabPersonalisation, tabIntegrations
- inputKeybindingsTitle, searchPlaceholder, searchNoResults, aboutMaintainersLabel
- integrationsPrivacyTitle, integrationsPrivacyBody
- aboutContributorsCount_one/_other (ru also _few/_many for proper plural forms)
- aboutDesc rewritten to describe a Navidrome-focused player instead of a
  generic Subsonic-compatible one.

Removed dead keys in all 8 locales:
- aboutAiCredit, aboutSpecialThanksLabel — no longer rendered
- changelog — inline changelog accordion removed
- tabServer, tabGeneral — superseded by new tab structure

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-22 02:25:56 +02:00
cucadmuh f6f76723d8 feat(servers/settings): magic string invites, Navidrome admin share, and add-user validation (#258)
* feat(servers): magic string invites and duplicate-aware labels

Add psysonic1- share payloads (URL, Subsonic credentials, optional server
name). Login and add-server forms decode on input and keep magic field
below standard fields. Navidrome User Management generates strings after
PUT password updates where required. Resolve duplicate display names as
username@host in chrome, settings, and login.

* feat(servers): tighten magic-string import and admin share flow

After a decoded magic string, username is read-only and the password field
shows a fixed-length mask so length is not inferred. Password reveal stays
disabled until saved-server quick connect (login) or reopening the add-
server form. Navidrome admin share UI persists the password via API before
copy and adds a plaintext-handling disclaimer. Locales updated.

* feat(settings): save new Navidrome user and copy magic string

Add a non-admin "Save and get magic string" flow: create the user, assign
libraries when needed, then encode credentials to the clipboard. Reuse
the plaintext-handling disclaimer without the password-update hint meant
for edits. Strings added for all locales.

* fix(settings): tighten Navidrome add-user validation and submit feedback

Require username, display name, and a non-empty password (trimmed) for new
users; gate Save and “save and get magic string” on explicit checks with
toast feedback. Show red borders only after a failed submit, and clear
them when the user fills the fields. When editing, keep an empty password
as “unchanged” and validate identity fields with a dedicated message.
Strings: userMgmtValidationMissingIdentity across locales.
2026-04-22 00:45:28 +02:00
cucadmuh b61c168430 fix(ui): overlay scrollbars, resizer hit-test, and Linux mini wheel (#255)
Add OverlayScrollArea with shared thumb metrics and drag handling; size the
thumb against the rail track height so panel insets cannot push it past the
visible rail. Route scroll uses a stable viewport id; Genres restores scroll
and infinite-scroll observation against that viewport (merged with upstream
virtualized genres list and lazy routes behind Suspense).

Suppress queue resizer activation when the pointer targets the main-route
overlay scrollbar; disable the resizer while dragging the thumb and use a
grabbing cursor on the body.

Apply WebKitGTK smooth wheel to the mini webview as well as main; the mini
window reapplies the persisted setting after auth store hydration.
2026-04-21 22:44:28 +02:00
Frank Stellmacher 2318f9e07a feat(albums): add 'Enqueue' option to album cover, context menu and multi-select toolbar (closes #253) (#256)
Per bcorporaal's request in #253: making a queue from multiple albums
required either replacing the queue (Play) or going into each album
detail page first. Three new entry points cover the common workflows:

1. Hover button on the album cover next to Play. Both buttons share
   the same accent style and size so the secondary action is just as
   readable as the primary — first attempt with a darker pill was hard
   to see and unbalanced the hover.

2. Context-menu entry "Enqueue Album" between "Open Album" and "Go to
   Artist". The i18n key already existed (was used by the album-song
   sub-context); just wiring up the action.

3. Multi-select toolbar in Albums.tsx: new "Enqueue (N)" button placed
   before "Add Offline" so power users selecting several albums no
   longer have to right-click. Plus a context-menu entry "Enqueue N
   Albums" for the same flow when a multi-album right-click happens
   anywhere else.

All multi-album fetches use Promise.all(getAlbum(...)) — Navidrome
handles parallel requests instantly (per memory note from PR #246).

i18n: 4 new keys per locale (3 with pluralisation: contextMenu.enqueueAlbums,
albums.enqueueSelected, albums.enqueueQueued).

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:39:46 +02:00
Frank Stellmacher f7a721b7b1 feat(artist-detail): user-configurable visibility and order of page sections (closes #252) (#254)
Per bcorporaal's request in #252: album-oriented listeners want a
cleaner artist page focused on the album grid, with the freedom to
reorder/hide bio, top tracks, and similar artists.

Five sections are now reorder- and toggle-able from Settings →
Personalisation → "Artist page sections": Bio, Top tracks, Similar
artists, Albums, Also featured on. The fixed header block (artist
photo, name, action buttons) stays at the top.

Implementation reuses the existing sidebar customizer pattern:

- new `src/store/artistLayoutStore.ts` modelled on `sidebarStore.ts`
  with the same persist + onRehydrateStorage migration so future-added
  sections don't silently disappear from existing users' configs.
- `ArtistDetail.tsx` render block refactored from a flat sequence of
  conditional JSX into a `renderableSectionIds.map()` driven by the
  store. The "first rendered section gets marginTop: 0" rule now
  works regardless of the configured order — both hidden-by-toggle
  and empty-data sections are filtered before the layout decides
  what's first.
- `ArtistLayoutCustomizer` component in Settings, lifted from
  `LyricsSourcesCustomizer` (drag via `useDragSource` + `psy-drop`
  event, per-row toggle, reset button).
- 7 new i18n keys per locale across all 8 languages.

The store hook MUST stay above the loading / !artist early returns —
otherwise React's hook call order mismatches between renders and the
component fails silently. Lesson learned during the refactor.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:08:39 +02:00
Frank Stellmacher c40243dfea perf(genres): paginate the genre grid + memoise sort to fix 30s cold-start freeze (#251)
Reported: opening the Genres page after a cold start froze the UI for
up to 30 seconds with a 500-genre Subsonic library. The page itself
was structurally trivial (one getGenres() call, sort, render) but each
card mounted a Lucide watermark SVG at size=80 — 500 complex SVGs
reconciled in one batch was the actual blocker, especially when other
cold-start useEffects were still draining the main thread.

Two changes:

- Pagination via IntersectionObserver, PAGE_SIZE 60. First paint mounts
  ~88% fewer cards; the observer pulls the next batch in 1500px ahead
  so the user never sees the end. Same pattern Albums.tsx uses.

- useMemo for the sort. Without it, every unrelated re-render
  (theme change, sidebar toggle) re-sorted all 500 entries.

Scroll-restore extended to also persist visibleCount in sessionStorage
so coming back from a GenreDetail page lands the user on a grid that
still contains the row they scrolled to.

Confirmed locally on a 500-genre library: page now renders instantly
on cold start.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:16 +02:00
Frank Stellmacher 0dfc3fcfe2 perf(artists): memoise filter pipeline and list-view grouping (#250)
Artists.tsx already paginates the DOM via PAGE_SIZE + IntersectionObserver,
so the page never holds more than 50-100 cards at a time. The actual
overhead with a 5000-artist library was the filter pipeline running on
every render — including renders triggered by selection mode toggles,
view-mode switches, image-toggle, etc. — re-walking the full artists
array twice per render (letterFilter + search filter), then re-building
the list-view groups Record from scratch.

Wrap the pipeline in useMemo:

- `filtered` recomputes only when artists / letterFilter / filter change
- `visible` recomputes only when filtered / visibleCount change
  (also keeps array identity stable across unrelated re-renders)
- `groups` + `letters` recompute only when visible / viewMode change,
  and skip the loop entirely in grid view (where they're unused)

No new dependency. With 5000 artists, unrelated state changes
(selection toggle, click on a card, scroll past pagination boundary)
are noticeably smoother.

Confirmed locally on a ~5000-artist library.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:11 +02:00
Frank Stellmacher aa0776e811 perf(bundle): lazy-load rarely-visited pages + split vendor chunks (#249)
Cold-start bundle was a single 1.88 MB / 577 KB-gzipped chunk that
parsed every page in the app before first paint, including pages a
typical session never opens.

Two-part fix:

1. Lazy-load 10 rarely-visited pages via React.lazy() — each becomes
   its own chunk that only downloads when the route is hit:
     Settings, Statistics, Help, WhatsNew, DeviceSync, OfflineLibrary,
     LabelAlbums, AdvancedSearch, FolderBrowser, InternetRadio.
   Whole <Routes> tree is wrapped in <Suspense fallback={null}> — no
   visible loading state, the browser cache hits on subsequent visits.

2. Vite manualChunks splits dependencies that change rarely from app
   code: react/react-dom/react-router, the @tauri-apps/* family, and
   i18next. Tauri auto-updater pulls smaller deltas when only app
   code changed (vendor chunks stay byte-identical and cached).

Build output:
  Before: 1.88 MB / 577 KB gz monolithic
  After cold start: ~450 KB gz (index 370 + react 54 + tauri 6 + i18n 20)
                    = 22% smaller initial download
  Plus 11 lazy chunks. Notably WhatsNew's bundled CHANGELOG (215 KB /
  76 KB gz) is now off the cold-start path entirely.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:07 +02:00
Frank Stellmacher 88cd09c0be perf(lyrics): persist resolved lyrics to IndexedDB across app restarts (#248)
The existing module-level Map<songId, CachedLyrics> only survived
within a session. After every app restart the lyrics fetch chain
(server → lrclib → netease) ran from scratch for every track the user
played, even ones whose lyrics were already resolved 5 minutes ago
before the previous session ended.

Add an IndexedDB layer that's only consulted on RAM miss:

- L1 (RAM): unchanged, sync, hit on tab switch / track repeat
- L2 (IndexedDB, async): hit after restart, hydrates RAM
- Network: unchanged fallback chain

Key format `${serverId}:${songId}` so the same songId on two different
Subsonic servers cannot collide.

TTLs: 90 days for resolved lyrics (they rarely change once shipped),
7 days for `notFound` entries (gives the user / admin a chance to add
lyrics later without an indefinite negative cache).

YouLyPlus mode skips L2 — a persisted entry from the standard pipeline
wouldn't have word-level sync, which is the whole point of lyricsplus.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:02 +02:00
Frank Stellmacher a76616b342 perf(albums): bump infinite-scroll prefetch margin from 200px to 1500px (#247)
The IntersectionObserver sentinel triggered the next page only when it
came within 200px of the viewport — by the time the user had scrolled
that far, they were already at the very edge waiting for the request.

Bumping rootMargin to 1500px (~1.5 screens at typical heights) prefetches
ahead of the scroll instead of behind it, so the next batch of albums is
visibly already there when the user reaches them.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:57 +02:00
Frank Stellmacher 1ec27f9aff perf(device-sync): parallelise getAlbum calls when syncing an artist source (#246)
fetchTracksForSource() iterated over an artist's albums with sequential
`await getAlbum(...)` inside a for-loop. A 50-album artist sync stalled
for ~7 seconds of round-trips before any device write started.

Switch to Promise.all(albums.map(...)). Same pattern ArtistDetail.tsx
already uses for "Play All" without issues — Navidrome handles parallel
getAlbum requests fine.

Per-album errors fall back to an empty song list rather than aborting
the whole sync.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:52 +02:00
Frank Stellmacher 9da1d643f7 perf(search): use CachedImage for result thumbs to stop re-render download storms (#245)
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.

buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.

While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.

Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:

    buildCoverArtUrl() generates a new ephemeral URL every call —
    browser cache is useless. Use coverArtCacheKey(id, size) +
    CachedImage for <img> tags.

Confirmed locally: typing in the search field is noticeably smoother.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:47 +02:00
Frank Stellmacher 06140a490b feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates (#244)
* feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates

A third tab in the right-side queue panel surfaces context for the
currently playing track:

- Artist card: image + biography from Subsonic getArtistInfo (Last.fm
  "Read more on Last.fm" anchor stripped), with read-more toggle that
  only appears when the bio actually overflows the 4-line clamp.
- Song info: contributor credits from OpenSubsonic contributors[]
  rendered stacked (name prominent, role muted). Section is hidden
  entirely on servers without contributor support, and rows that just
  re-state the main artist under role "artist" are filtered out.
- On tour: optional Bandsintown integration (opt-in, off by default).
  HTTP fetch + JSON parsing happen entirely on the Rust side; the
  frontend wrapper deduplicates concurrent calls and caches results in
  RAM for the session. Limited to 5 events with a "Show N more" toggle.
  When the toggle is off, the section becomes an in-place opt-in card
  with a privacy info-tooltip explaining what data is sent — same
  tooltip is also exposed on the matching toggle in Settings.

Caching: artist info and song detail are memoised by stable IDs across
component remounts, so jumping between tracks of the same album/artist
does not refire the network calls.

Implementation notes:
- Bandsintown app_id "js_app_id" — arbitrary strings (e.g. "psysonic")
  now return HTTP 403 from rest.bandsintown.com; js_app_id is the ID
  Bandsintown's own embeddable widget uses and is broadly accepted.
- Tour items use negative left/right margins so the date badge stays
  visually aligned with the section title while the hover background
  extends slightly past the section edges.
- New i18n namespace nowPlayingInfo across all 8 locales (en, de, fr,
  nl, zh, nb, ru, es) including pluralised "Show N more" forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(now-playing-info): switch nested flex-columns to block layout to stop content-dependent drift

Repeated reports of the Tour and Song-info sections rendering at
inconsistent x-positions (sometimes left of the section title,
sometimes centred, sometimes correctly aligned) — varying by artist,
sidebar width, and even whether "Show more" was clicked.

Root cause: nested flex-direction: column containers
(.np-info → .np-info-section → .np-info-tour / .np-info-credits) where
the cross-axis (horizontal) sizing on WebKitGTK occasionally inherits
the intrinsic min-content of the longest child. With one
"Naherholungsgebiet/Freizeitgelände Lago Alfredo"-style venue name
that overflows the sidebar, the parent ul gets pushed past 100% width
and the entire layout drifts. min-width: 0 on every level didn't help
because cross-axis stretch in flex-column ignores it for content-driven
overflow.

Fix: collapse all the section/list containers to display: block. Block
layout is content-agnostic — children always render at 100% parent
width, left-aligned, deterministically. Spacing between siblings now
uses the lobotomy selector (`> * + * { margin-top }`).

Only the tour item itself stays flex (badge ↔ meta horizontal layout),
and that one still has overflow: hidden + flex: 1 1 0 + min-width: 0
on the meta column to truncate venue/place text with ellipsis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:13:18 +02:00
Frank Stellmacher 76ed6eca55 feat(replaygain): add Auto mode that picks track vs album gain from queue context (#242)
Auto mode is the new default for ReplayGain. It picks album gain when an
adjacent queue neighbour shares the same albumId (so "Play All" on an
album, or any contiguous album block, gets album gain), otherwise track
gain. Falls back to track gain when album gain is missing.

- authStore: replayGainMode widened to 'track' | 'album' | 'auto', default 'auto'
- playerStore: new resolveReplayGainDb(track, prev, next, enabled, mode) helper
  replaces five inline ternaries (audio_play hot path, gapless preload,
  cold-resume success + fallback, live update_replay_gain)
- Settings: third "Auto" button + contextual hint when active
- i18n: replayGainAuto + replayGainAutoDesc added to all 8 locales

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:12:19 +02:00
Frank Stellmacher fb02b62a54 fix(titlebar): hide traffic-light glyphs until hover (#243)
* fix(titlebar): hide traffic-light glyphs until hover

The lucide Minus/Square/X icons at 9×9 with stroke-width 2.5 looked
chunky at all times — especially the Square for maximize. Match real
macOS behaviour: glyphs fade in only when the controls group is hovered
(or a button is keyboard-focused).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(titlebar): drop traffic-light glyphs entirely, add coloured hover glow

Following review feedback: even on-hover the lucide glyphs at 9×9
weren't pleasant. Remove them outright and signal hover with a soft
coloured glow matching each button (red/yellow/green) plus a subtle
brightness lift. Keyboard focus uses a white outer ring for accessibility.

- TitleBar.tsx: remove lucide imports + icon children, add aria-label
- layout.css: drop svg sizing rules, add per-variant box-shadow hover,
  add focus-visible ring, transition box-shadow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:11:24 +02:00
cucadmuh bac0afe6ae fix(subsonic): sync Rust HTTP UA with main webview UA (#235)
Use the main window WebView user agent as the runtime source for Rust-side HTTP clients and refresh the audio engine client when the UA changes. This keeps backend and WebView requests aligned while preserving a simple startup sync flow.
2026-04-21 19:04:04 +02:00
Frank Stellmacher 3b3833007b feat(logging): add runtime log levels and debug log export (#241)
Add a System setting for Off/Normal/Debug logging, apply readable local timestamps to backend logs, and enable exporting buffered runtime logs to a file when debug mode is active.

Co-authored-by: Maxim Isaev <im@friclub.ru>
2026-04-21 12:12:54 +02:00
Frank Stellmacher fa21379dbb fix(player): prevent streaming seek UI freezes and progress snapbacks (#236)
* fix(player): prevent streaming seek UI freezes and progress snapbacks

Move blocking seek work off the command path with a bounded timeout, switch fullscreen/mobile seek bars to commit-on-release, and stabilize fallback progress state so rapid early seeks on streamed tracks do not freeze the UI or jump progress to zero.

* fix(player): avoid second-seek lockups and progress flicker

Add a short lock timeout for audio seek state access to prevent UI stalls when rapid seeks overlap, and keep waveform progress stable right after seek commit to eliminate brief visual snapbacks.

* fix(player): harden seek recovery and reduce stream log noise

Keep seek fallback stable during delayed backend responses to prevent occasional resets to track start, and remove per-read stream blocking logs so normal playback diagnostics stay readable.

---------

Co-authored-by: Maxim Isaev <im@friclub.ru>
2026-04-21 12:10:26 +02:00
Frank Stellmacher c61bcacd0d feat(mobile): comprehensive mobile UI overhaul (#238)
RandomMix: filters and genre mix panels collapse on mobile
- RandomAlbums / album grids: auto-fill favouring 3 columns at narrow widths
- BottomNav: More button opens a bottom sheet with remaining nav items
- MobileMoreOverlay: new sheet component with backdrop and slide-up animation
- Tracklist: mobile Title / Artist two-line stacked layout (Apple Music style)
- ArtistDetail: centred header (photo → name → buttons), icon-only buttons
  except Play All, collapsible Similar Artists (5 default), 2-column album
  grid, avatar glow derived from dominant cover colour
- Settings: fixed overflow in ReplayGain, Crossfade, mix rating filters,
  theme scheduler, and Next Track Buffering sections

Co-authored-by: kilyabin <65072190+kilyabin@users.noreply.github.com>
2026-04-21 12:10:02 +02:00
Frank Stellmacher f73cca669b edit(FullScreenPlayer): Possible halos in the background have been removed (#239)
Co-authored-by: kilyabin <65072190+kilyabin@users.noreply.github.com>
2026-04-21 12:09:38 +02:00
Frank Stellmacher a1427202a9 docs(privacy): add YouLyPlus lyrics backend note (#240)
* edit(PRIVACY): added note about YouLy+ lyrics backend

* fix(PRIVACY): fixed link

---------

Co-authored-by: kilyabin <65072190+kilyabin@users.noreply.github.com>
2026-04-21 12:08:52 +02:00
Frank Stellmacher ae92c452b6 chore(aur): bump pkgver to 1.43.0 (#229)
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:08:38 +02:00
Frank Stellmacher 1ce199ee4b fix(ci): drop --auto from nix-lock PR merge (#227)
GitHub rejects \`gh pr merge --auto\` when the PR has no required
status checks and no required reviews — the error is "Pull request
is in clean status (enablePullRequestAutoMerge)". Our ruleset
requires neither, so auto-merge has nothing to wait for. Switch
to a direct merge instead. If we ever add required checks, --auto
can come back.

Validated against the v1.43.0 test release: PR #226 was created
by the workflow but the auto-merge call failed; merging it
manually with the same flags minus --auto succeeded immediately.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:00:37 +02:00
github-actions[bot] cb86bd7602 chore(nix): refresh lock + npmDepsHash for v1.43.0 (#226)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 23:59:12 +02:00
Frank Stellmacher 77598e10e9 chore(release): v1.43.0 (#225)
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:34:05 +02:00
Frank Stellmacher e7bfe276c3 ci(release): route nix lock refresh through PR + auto-merge (#224)
After enabling branch protection rulesets on main, the existing
direct push from the verify-nix job (`git push origin HEAD:main`)
is rejected. Push the refresh commit to a per-release branch
(`chore/nix-lock-refresh-v<VERSION>`) instead, open a PR, and
enable auto-merge so the change lands as soon as repo conditions
allow (0 required reviews + 0 required status checks → instant).

Adds `pull-requests: write` to job permissions so GITHUB_TOKEN can
create + merge the PR.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:16:25 +02:00
Frank Stellmacher f510223d2d feat(settings): compact spacing pass + row hover affordance (#223)
Tightens the Settings panels globally: section margin-bottom 32→20px,
card padding 20→12px, divider margin 12→8px, section header h2
16→15px. Adds a subtle accent-tinted hover background on every
.settings-toggle-row (extended to card edges via negative
horizontal margin). Vertical row padding kept at 2px so the diet
isn't undone by the hover affordance.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:58:19 +02:00
Psychotoxical 2d3be10c8f feat(users): show last-access timestamp per user
Maps Navidrome's lastAccessAt onto NdUser and renders it in the
User Management row as a localized relative time (Intl.RelativeTimeFormat
with the current i18n language). Tooltip carries the absolute
timestamp. Navidrome returns 0001-01-01 for never-accessed users —
detected and shown as the localized "Never" label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:26:58 +02:00
Frank Stellmacher 459c9f688d feat(users): per-user library assignment + themed confirm modal (#222)
Adds Navidrome library assignment to the User Management settings
panel: GET /api/library + PUT /api/user/{id}/library wired through
new Rust commands. UserForm gets a checkbox picker (hidden for
admins, who auto-receive all libraries server-side) with inline
validation. User rows compacted to a single clickable line with
hover highlight; native confirm() replaced by a portal-based
ConfirmModal (theme-aware, ESC/Enter, vertically centered).

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:18:10 +02:00
Psychotoxical 6d3c50264a fix(mini): minimize main on open + cap width on non-tiling WMs
Restore the main-window minimize/unminimize behavior when opening the
mini player on Windows — the original WebView2 stall no longer applies
since the mini window is pre-created at startup. Also cap the mini
window at 400 px width so horizontal drag can't stretch the layout
across a whole monitor; skipped on tiling WMs (Hyprland, Sway, i3)
where the compositor manages sizing itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:13:57 +02:00
Psychotoxical 9f21edee80 feat(mini): queue-style meta block, action toolbar, vertical volume slider
Restructure the MiniPlayer to mirror the main window's queue panel layout:

UI changes:
- Meta block: cover + title/artist/album/year (year added to MiniTrackInfo)
- Action toolbar styled like .queue-round-btn (30px round, accent fill when
  active), in order: volume, shuffle | gapless, crossfade, infinite | queue
- Volume button opens a thin 5px vertical strip slider that drops down (click
  / drag / wheel to adjust). Right-click on the volume button mutes.
- Controls + progress moved to the bottom as a true footer (margin-top: auto),
  so they stay anchored even with the queue expanded
- Queue toggle moved out of the titlebar into the action bar (logically lives
  with the other queue/playback toggles)
- Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout

Bridge changes (miniPlayerBridge.ts):
- MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled,
  infiniteQueueEnabled
- Bridge now subscribes to authStore in addition to playerStore so toolbar
  toggle states propagate cross-window
- New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade,
  mini:set-infinite-queue
- Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha)
  so the mini doesn't need to know about both states to act

Misc:
- Belt-and-suspenders user-select: none on .mini-player-shell * to kill
  Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through
2026-04-20 18:51:31 +02:00
Kveld. a7eb0eda72 fix padding (#221) 2026-04-20 17:41:48 +02:00
Kveld. 9687f90b54 fix: artist top songs continue playback (#220)
* fix: continue playback after top songs finish on artist page

* comments to english
2026-04-20 17:41:44 +02:00
Psychotoxical ccf6e6c886 fix(seekbar): commit seek on mouseup + ignore progress ticks while dragging
Ports the WaveformSeek UX changes from PR #219 (original fix by @cucadmuh)
onto test/seekable-streaming:

- Split previewFraction (during drag) / commitSeek (on mouseup) so audio_seek
  fires once per gesture instead of on every mousemove.
- Subscribe-effect skips external progress updates while isDragging — prevents
  cursor flicker from streaming/recovery ticks fighting the dragged position.
- Clear seekTarget in seek() catch branch so a failed seek doesn't permanently
  block progress updates.

Skips the byte-restart fast-path from #219: would defeat RangedHttpSource's
direct seek by restarting the stream after 450ms.

Co-Authored-By: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:28:20 +02:00
Psychotoxical 5aac4d4ed0 feat(audio): seekable streaming via RangedHttpSource + instant local playback
Reworks the manual streaming-start path so playback is seekable from the
first frame and hot-cache hits start without a multi-hundred-MB pre-read.

Why
- The legacy AudioStreamReader was a non-seekable ring buffer. Forward
  seeks during streaming let Symphonia consume the buffer through to EOF
  (next song), and backward seeks were silently broken.
- fetch_data() loaded `psysonic-local://` files via tokio::fs::read,
  blocking playback for seconds on hot-cache or offline files (e.g. a
  100+ MB hi-res FLAC) and producing ALSA underruns when the new sink
  swap arrived too late.

What changed
- New RangedHttpSource MediaSource: pre-allocates a Vec<u8> in track
  size, background ranged_download_task fills linearly from offset 0
  with HTTP Range reconnects. is_seekable=true; reads block on data,
  seeks update the cursor only. Picked when the server response carries
  Accept-Ranges: bytes + Content-Length.
- New LocalFileSource MediaSource: thin std::fs::File wrapper used for
  every `psysonic-local://` URL — Symphonia probes the first ~64 KB and
  starts playback before the rest is read.
- audio_play wires both paths through a generic PlayInput::SeekableMedia
  variant so the build_streaming_source pipeline stays single-shape.
- current_is_seekable AtomicBool on AudioEngine: audio_seek short-circuits
  with `not seekable` for the legacy fallback so the frontend restart-
  fallback (playerStore seek catch) engages instead of letting a forward
  seek consume the ring buffer.
- Dev-only [stream] / [seek] eprintln logs (cfg(debug_assertions)) so
  source selection, codec resolution (codec name + lossless flag + bit
  depth + rate + channels), download progress and seek targets are
  visible in the terminal during testing.
- content_type_to_hint now covers wav/wave/opus; URL-derived format
  hints strip the query string first and only accept known audio
  extensions (Subsonic stream URLs have no extension — would otherwise
  latch onto query-param fragments).

Compatibility
- Servers without Accept-Ranges fall back to the existing AudioStreamReader
  path; seek is rejected up-front so the existing restart-fallback runs.
- Radio + audio_chain_preload paths untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:27:31 +02:00
Psychotoxical ccff7be499 chore(settings): drop Alpha badge on AudioMuse toggle
Also removes the now-unused `hotCacheAlphaBadge` i18n key from all
8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:53:10 +02:00
Psychotoxical 99fc62e505 fix(themes/jayfin): WCAG AA contrast fixes for nav + primary buttons
Active nav-link: raise background to 22% accent + switch text/icon to
white — lifts from 3.30:1 to >10:1 (was AA-small fail). Left purple bar
preserves the Jellyfin brand cue.

Primary buttons (btn-primary, player-btn-primary, hero-play-btn,
album-card-details-btn, queue-round-btn.active): force font-weight 700.
White on #AA5CC3 measures 4.08:1 — bold glyphs compensate visually so
button labels remain clearly legible without shifting the brand color.

Not touched: accent-as-text on bg-card (4.09:1, needs separate
--accent-text token), hover tint #be70d8, white on danger red
(cross-theme issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:45:14 +02:00
Psychotoxical dc819f3a2c feat(settings): admin-gated User Management tab via Navidrome REST API
Adds a dedicated Settings tab for managing Navidrome users (list, create,
edit, delete). Only visible to admins — gated by `/auth/login` probe on
mount. Uses Navidrome's native /api/user endpoints instead of Subsonic
(getUsers.view etc. return only the caller on Navidrome). All HTTP calls
go through new Rust commands to bypass WebView CORS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:35:14 +02:00
Psychotoxical 69dd374e80 chore(credits): add PRs #214, #215, #216, #217 to Settings → About
kilyabin: #214 (ease-out lyrics scroll + plain-lyrics bottom fade),
#215 (single-line YouLy+ source strings).
kveld9: #216 (opt-in floating player bar), #217 (Linux GPU-vendor
auto-detection for the WebKitGTK DMA-BUF renderer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:15:21 +02:00
Psychotoxical 36caf3a14c refactor(linux): simplify GPU-vendor detection for DMA-BUF toggle
Follow-up on #217. Blind A/B test on NVIDIA + proprietary confirmed the
toggle helps, but the detection path had a few rough edges.

- Drop lspci and glxinfo fallbacks: the /proc/driver/nvidia/version +
  sysfs scan catch every realistic case, and spawning subprocesses in
  main() added startup latency for no win (glxinfo also isn't always
  installed — mesa-utils isn't a hard dep).
- Scan all /sys/class/drm/card* entries via read_dir instead of
  hardcoded card0/card1 — hybrid laptops and systems where only card1
  exists were previously missed.
- Remove 0x1022 from the AMD list: that's AMD's host/chipset vendor ID,
  not GPU. Only 0x1002 (ATI/Radeon) belongs here.
- Unknown vendor → leave WEBKIT_DISABLE_DMABUF_RENDERER unset instead
  of forcing =1. VMs, ARM SBCs and anything exotic keep the WebKitGTK
  default and do not get regressed by a guess.
- Only set the env var for the NVIDIA case. Intel/AMD on Mesa already
  default to DMA-BUF enabled in current WebKitGTK, so the explicit =0
  was redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:08:59 +02:00
Frank Stellmacher d145a7758e Merge pull request #217 from kveld9/feat/linux-gpu-dmabuf-autodetect
feat(linux): Auto-detect GPU vendor and configure DMA-BUF renderer
2026-04-19 22:07:39 +02:00
kveld9 fc0b8fbcab introducing WEBKIT_DISABLE_DMABUF_RENDERER 2026-04-19 15:40:29 -03:00
Psychotoxical 5a51c2c713 feat(floating-bar): liquid-glass look on macOS and Windows
Inspired by kveld9's PR #211 proposal. Adapted to stay within WebView2
and WKWebView GPU budgets — 8px blur instead of 28, theme-token colours
via color-mix instead of hardcoded white, no saturate/brightness/
contrast stack, no transitions, no !important.

Linux keeps the solid floating bar from PR #216 on every compositing
mode. Platform is detected once on mount via a data-platform attribute
on <html>.

Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:00:10 +02:00
Psychotoxical fab1dbf863 perf(floating-bar): subscribe themeStore via selector
Both AppShell and PlayerBar read floatingPlayerBar via destructured
useThemeStore() without a selector, so every unrelated theme change
(accent, font, scheduler state, …) would force a re-render. Switch to
s => s.floatingPlayerBar so these components only re-render when the
toggle actually flips.

Follow-up to PR #216.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:44:17 +02:00
Frank Stellmacher 8a0810ffb4 Merge pull request #216 from kveld9/feat/floating-bar-toggle-v2
feat: floating player bar with toggle
2026-04-19 19:43:14 +02:00
Frank Stellmacher 6e34dc8160 Merge pull request #215 from kilyabin/am-lyrics-improvements
fix(lyrics): sidebar lyrics strings with YouLy+ source shows in one line
2026-04-19 19:37:57 +02:00
Psychotoxical 2aa88d26d4 chore(lyrics): rename springScroll.ts to easeScroll.ts
After PR #214 replaced SpringScroller with EaseScroller, the filename
no longer matched the exported class. Rename for clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:35:51 +02:00
kilyabin 22fe0fff74 fix(lyrics): sidebar lyrics strings with YouLy+ source shows in one line 2026-04-19 21:24:58 +04:00
kveld9 12bebdde06 some fixes 2026-04-19 14:23:14 -03:00
Frank Stellmacher 50c886fd6f Merge pull request #214 from kilyabin/am-lyrics-improvements
refactor(fs-lyrics): smoother line scrolling + bottom fade for plain lyrics
2026-04-19 19:20:02 +02:00
kilyabin 65413c954e feat(fs-lyrics): fade bottom edge of plain lyrics scroll viewport
Synced lyrics always fill the screen due to auto-scroll, so the
existing fsa-fade-bottom overlay naturally fades content below
the active line. Plain (unsynced) lyrics start from the top and
may not fill the viewport, leaving no content for that overlay
to fade.

Add mask-image to .fsa-lyrics-container when rendering plain
lyrics (--plain modifier class). The mask operates on the visible
scroll viewport rather than the content, so the bottom fade is
always present regardless of scroll position.
2026-04-19 21:00:46 +04:00
kilyabin 059900e85e refactor(lyrics): replace spring scroller with cubic ease-out animator
The spring model (velocity × damping per rAF frame) produced an
abrupt-looking lurch on each line change — especially noticeable at
the stiffness/damping values used (0.1 / 0.78).

Replace SpringScroller with EaseScroller: a fixed-duration (650 ms)
cubic ease-out animation that starts from the current scroll position
and decelerates smoothly to the target. Restarting mid-flight is clean —
the next call captures the container's current scrollTop as the new
start point, so fast line changes never skip or jerk.

Applied to both the fullscreen Apple-style lyrics and the sidebar
LyricsPane. User-scroll cancellation (stop() + 4 s resume) is
preserved unchanged.
2026-04-19 20:42:36 +04:00
Psychotoxical dea9d9b5a4 fix(settings): lyrics-sources DnD survives mode toggle
The psy-drop listener was attached in a useEffect keyed only on
setLyricsSources, so when the container was unmounted by the
{lyricsMode === 'standard' && …} wrapper (switching to YouLyPlus and
back), the listener stayed bound to the old DOM node and drag-to-reorder
silently did nothing.

Switch the container ref to a callback-ref stored in state, so the
effect re-runs on mount/unmount and always listens on the current DOM
node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:23:34 +02:00
Psychotoxical d191404f2d Revert "perf(fs-player): cut CPU under WebKitGTK software compositing"
This reverts commit 77edfaa867.

Restores the full Apple Music-style lyrics visuals from PR #205 on Linux:
rAF spring scroller, per-line scale transforms, text-shadows, title glow,
album-art/play-button outer glows, mesh-blob radial gradients, and the
FS-art opacity crossfade. PR #205's author noticed the Linux codepath
lost the intended feel — the CPU tradeoff is accepted.

The trigger-ref fix from 25537f27 for the lyrics menu stays in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:07:13 +02:00
kveld9 ffffe268ab introducing floating bar with toggle 2026-04-19 13:01:31 -03:00
Psychotoxical 81ed6db9d1 style(folder-browser): auto-contrast text on selected row
Replace hardcoded #fff on `.folder-col-row.selected` with a relative
OKLCH expression that picks black or white based on the accent's
lightness. Keeps rows readable on bright-accent themes (Muma, pastel
sets) without per-theme overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:44:08 +02:00
Psychotoxical 6878867e64 style(titlebar): theme-independent traffic-lights + song pill
Window controls use fixed macOS-style colored dots (red/yellow/green)
so they stay visible on every theme (broke on Middle-Earth etc. where
text tokens collapsed against the sidebar bg). Song indicator becomes
a dark translucent pill with light text + text-shadow for universal
contrast. Removed the "Psysonic" label — the sidebar logo already
covers it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:37:35 +02:00
Psychotoxical f53f724e1c chore(aur): bump pkgver to 1.42.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:55:16 +02:00
Psychotoxical 9bcef81b22 chore(release): v1.42.1
Critical Windows bug-fix release.

Fixed
- Mini player no longer hangs the app on Windows (see 71fbc717): the
  second WebView2 is now pre-built in .setup() so the first open is a
  pure show/hide instead of a creation + minimize race. Windows is
  back on native window decorations for the mini and skips the main-
  window minimize/unminimize dance around show/hide.
- Mini player re-emits mini:ready on window focus so the snapshot from
  main arrives reliably on first open even when pre-create finished
  before main's bridge attached its listener.

Added
- "Preload mini player" toggle in Settings → General for Linux + macOS
  so those platforms can opt into the instant-open behaviour that
  Windows already has as a hang workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:52:13 +02:00
Psychotoxical 693766134b feat(mini-player): optional preload toggle for Linux + macOS
Adds a "Preload mini player" toggle in Settings → General → App behaviour
(off by default). When enabled, the mini webview is built hidden at app
start so the first open is instant, matching the Windows experience.
Costs one extra WebKit process running in the background (~50–100 MB).

Windows already pre-creates the mini unconditionally as a hang workaround
(commit 71fbc717); the toggle is hidden there and the invoke is skipped,
so that platform is untouched.

- authStore: new `preloadMiniPlayer: boolean` (default false) + setter.
- lib.rs: new `preload_mini_player` command; idempotent, no-op when the
  window already exists. Registered in the invoke handler.
- App.tsx: main window invokes `preload_mini_player` when the toggle is
  on and the platform is not Windows.
- Settings.tsx: toggle row under "Minimize to Tray", gated with
  `!IS_WINDOWS` so Windows users don't see it.
- i18n: `preloadMiniPlayer` + `preloadMiniPlayerDesc` added to all 8
  locales (en, de, es, fr, nb, nl, ru, zh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:52:03 +02:00
Psychotoxical 71fbc717f6 fix(mini-player): unhang Windows by pre-creating the mini webview
Creating the second WebView2 webview lazily from the open_mini_player
invoke handler reliably stalled Tauri's event loop on Windows — the mini
opened blank white, neither main nor mini could be closed, and the user
had to kill the process via Task Manager. The builder-then-minimize-main
combo racing with WebView2's first paint seems to be the trigger.

Fix: extract a build_mini_player_window helper and call it once in
.setup() on Windows so the webview is created (hidden) in the startup
context, not from an invoke command. open_mini_player is now a pure
show/hide on all platforms. Windows also skips the main.minimize() call
since that interacted with the hang; macOS + Linux keep the existing
behaviour of minimising main when the mini opens.

Other changes bundled in:
- Switch Windows from the custom in-page titlebar back to native window
  decorations (the earlier decorations(false) move on Windows was part
  of the hang surface — safer to keep the OS chrome there, Linux still
  uses the custom bar).
- Re-emit mini:ready on window focus so every open of the pre-created
  mini forces a fresh snapshot from main's bridge, even when the mount-
  time emit raced past the bridge during startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:31:45 +02:00
Psychotoxical d33c7042b6 fix(mini-player): show action buttons on macOS too
The custom titlebar was rendered only on Win/Linux, so macOS users had
no way to toggle the queue, pin-on-top or expand back to the main
window — the native macOS titlebar takes care of dragging + close, but
those three actions live entirely in app code.

Render a slim transparent in-page strip on macOS as well, holding just
the queue / pin / maximize buttons right-aligned. Close is omitted on
macOS since the red traffic light already does that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:06:46 +02:00
Psychotoxical 61c17d2e24 chore(warnings): silence cpal lifetime + unused tray import on Windows
- patches/cpal-0.15.3 wasapi device.rs: annotate MutexGuard return with
  '_ so mismatched_lifetime_syntaxes stops firing.
- lib.rs: gate the MouseButtonState import to non-Windows targets (the
  Windows tray uses DoubleClick and never pattern-matches it).

Cosmetic only, no behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:56:27 +02:00
github-actions[bot] e4abaf8814 chore(nix): refresh lock + npmDepsHash for v1.42.0 2026-04-19 11:54:46 +00:00
Psychotoxical c17fc0f6ac chore(release): v1.42.0
Consolidates everything since v1.40.0 (public) into one coherent
release. v1.41.0 remains as an internal Draft on GitHub that was used
to verify the Cachix substituter pipeline and never went public — this
release folds its intended contents in and adds the mini-player feature
work, the player-bar time toggle (kveld9), the ReplayGain expand badge
and related UX polish on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:40:57 +02:00
Psychotoxical 16491f6321 feat(mini-player): custom titlebar with action icons; cover/meta/controls layout polish
- Drop the native titlebar on Windows + Linux (decorations: false in
  open_mini_player); macOS keeps the system titlebar with traffic lights.
- Add a slim 26 px custom titlebar with drag region, current track
  title, and the queue / pin / open-main / close action icons.
- Remove the bottom toolbar — those four buttons now live in the
  titlebar where they're easier to reach.
- Refresh the player layout: cover shrinks 112 → 84 px, the right
  column gets title / artist / transport in a single 84 px-tall block,
  progress bar spans the full width below.
- Add a miniPlayer i18n namespace (showQueue, hideQueue, pinOnTop,
  pinOff, openMainWindow, close, emptyQueue) localized for all 8
  supported locales — previously the tooltips were hard-coded English.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:28:28 +02:00
Psychotoxical d8244e0139 docs(about): credit kveld9 for player-bar time-toggle (PR #212) 2026-04-19 13:10:16 +02:00
Psychotoxical 60da17f7cc feat(mini-player): user-bindable keyboard shortcut to toggle mini player
Adds an 'open-mini-player' entry to the in-app keybindings (Settings →
Shortcuts), default unbound. Pressing the chord from the main window
opens the mini and minimises main; pressing it from the mini hides the
mini and restores main — both paths invoke open_mini_player which
already handles the toggle.

The mini window has its own keydown listener (same pattern as Space /
arrows for play/skip) that reads the binding from useKeybindingsStore.
Binding changes in main propagate to an open mini through the existing
storage-event sync (now also covers psysonic_keybindings) so the user
doesn't need to restart the mini after rebinding.

Localized in all 8 supported locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:06 +02:00
Psychotoxical 8cc115cade fix(mini-player): restore window position + queue-open state across launches
Two regressions in the mini player's persistence story:

1. Window position was lost on every reopen. set_position() called on a
   hidden window is unreliable on Linux WMs (Mutter, KWin re-centre on
   show). Worse, the WM-induced re-centre fired WindowEvent::Moved with
   the centre coords, which the throttled persister happily wrote to
   disk — so the saved position turned into "centre" within seconds.

   Fix:
   - Initial open uses WebviewWindowBuilder::position() (logical pixels,
     scaled via the primary monitor) so the window is created at the
     right spot.
   - Re-show path re-applies the saved position with set_position AFTER
     show().
   - Both paths call mark_mini_pos_programmatic() before triggering the
     move; persist_mini_pos_throttled ignores Moved events within 1 s of
     a programmatic mark, so WM-induced and self-induced moves no longer
     overwrite the user's position.

2. The queue panel always opened collapsed regardless of the previous
   session. Persist queueOpen to localStorage (psysonic_mini_queue_open)
   and resize the window to the stored expanded height on mount when
   the saved state was 'open'. Brief jump from 180 px to expanded is
   unavoidable since localStorage only lives in the JS layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:54:00 +02:00
Psychotoxical e07ef0ebf1 feat(queue): collapse ReplayGain into a click-to-expand badge
Per @cucadmuh's feedback: the presence of ReplayGain matters more than
the actual numbers. The tech bar now shows a small 'RG' pill on line 1
whenever the track has any RG metadata; hovering reveals the values via
tooltip, clicking persistently expands the second line.

- Source icon stays inline before the format string (was getting
  separated from FLAC by the centred stack layout)
- Pill uses --accent-tinted color-mix so it adapts to every theme
- ChevronDown rotates on expand for a clear affordance
- New persisted state: themeStore.expandReplayGain (default collapsed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:34:52 +02:00
Psychotoxical 6456f13bde fix(player-bar): contain paint to prevent black-flash on WebKitGTK
The player bar occasionally renders fully black for one frame on Linux
(WebKitGTK with software compositing) when an unrelated layer elsewhere
in the page invalidates. `contain: layout paint` makes the bar its own
paint boundary so it can no longer be pulled into a surrounding dirty
rect.

No-op on Wayland-with-GPU and on Chromium-based webviews (Windows,
macOS) — those don't exhibit the flash to begin with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:28:22 +02:00
Psychotoxical a48159d302 feat(queue): split tech bar into two lines when ReplayGain is present
The tech info strip in the queue header was a single ellipsised line, so
ReplayGain values (Track / Album / Peak) got cut off on tracks with a
full codec + bitrate + samplerate prefix. Now the strip wraps into two
lines whenever RG values exist:

- Line 1: codec · bitrate · bit depth/sample rate (unchanged)
- Line 2: 'ReplayGain · T … dB · A … dB · Peak …' — slightly smaller
  and dimmed for hierarchy, ellipsises independently if it still
  overflows.

Tracks without RG metadata stay one line as before. New i18n key
`queue.replayGain` ('ReplayGain') added to all 8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:24:46 +02:00
Psychotoxical 2871db9a96 feat(mini-player): persistent geometry, queue DnD + context menu, overlay scrollbar, live theme sync
This iteration fills in the pieces that make the mini player usable as a
daily standalone window:

- Window position persists to <app_config_dir>/mini_player_pos.json on
  every WindowEvent::Moved (throttled 250 ms); first launch lands in the
  bottom-right of the main window's monitor.
- Queue keeps a 260 px floor when expanded (~2 visible rows). The
  user-resized expanded height is restored on next toggle via
  localStorage so reopening doesn't snap back to the default 440 px.
- set_mini_player_always_on_top(true) now forces a false-then-true cycle
  so the WM re-evaluates the layer after a hide/show; the frontend also
  re-asserts the pin state on mount and on window focus, so the user no
  longer has to click the pin button twice for it to stick.
- Native scrollbar in the queue is hidden in favour of a JS-driven
  overlay thumb so items use the full width of the queue area.
- PsyDnD reorder works inside the mini queue: drag emits 'mini:reorder',
  the bridge calls reorderQueue on the source-of-truth store in main.
- Localized queue-item context menu (MiniContextMenu) with Play now /
  Remove from queue / Open album / Go to artist / Favorite / Song info.
  Cross-window actions forward to main via new bridge events
  (mini:reorder, mini:remove, mini:navigate, mini:song-info); a
  psy:navigate CustomEvent picked up by AppShell handles routing.
- Theme, font and language changes in main propagate live to the mini
  via a 'storage' event listener that re-hydrates the persisted Zustand
  stores and calls i18n.changeLanguage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:08:05 +02:00
Psychotoxical 42ad24cce1 fix(player-bar): use data-tooltip on time toggle (not native title)
Native `title` attribute renders an unstyled OS tooltip and bypasses the
TooltipPortal — convention in this codebase is `data-tooltip="…"` so the
hover hint matches every other player-bar control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:04:29 +02:00
Kveld. eb747ba1ae introducing timer toggle player bar (#212) 2026-04-19 12:04:01 +02:00
Psychotoxical 6bd2bfc01c feat(changelog): replace auto-modal with sidebar banner + /whats-new page
Removes the giant startup changelog modal. After an update, a compact
neutral-palette pill now sits above Now Playing in the sidebar saying
"Changelog — vX.Y.Z". Clicking opens a proper /whats-new page in the
main content area; X dismisses. Page renders the current version's
CHANGELOG entry with inline Markdown (headings, lists, blockquotes,
hr, bold/italic/code, and real [label](url) links that open via the
Tauri shell plugin).

Visual tokens are hardcoded slate/gray/blue so the banner and page look
identical across every theme (dark, light, skeuomorphic, whatever) —
requested for consistent contrast. Auto-mark-as-seen removed from the
page; the banner only goes away on explicit dismiss, so users can
re-read as often as they want.

Settings → About gains a "Release notes" row with a link that resets
the seen-version and opens the page, so the banner can be retriggered
manually (also helpful for dev builds ahead of the current tag).

The existing "Show changelog on update" toggle now gates the banner
instead of the modal; description strings updated in all 8 locales.

fix(themes): WCAG contrast audit — mocha + winmedplayer + wista

Mocha (Catppuccin dark)
  .track-size used --ctp-overlay0 directly (3.36:1 on bg-app, 2.57
  on bg-card). Swapped to --text-muted → 7.37 / 5.65. Fix also lifts
  macchiato/frappe/latte which shared the failure.

WinMedPlayer (Luna)
  --text-muted #b8d0f8 (3.87:1 on bg-app) → #e8f0ff (5.28)
  --border   #2a5090 (1.31 on bg-app)   → #071027 (3.12, meets 3:1 UI)

Wista (Vista Aero)
  Lyrics pane sits on bg-sidebar #0e1e3e but used --text-primary
  (#0d1d3c) for active lines — 1.01:1, literally invisible. Added
  component overrides: .lyrics-line / .lyrics-status / word-synced
  variants → #aac8f0 (9.60), .lyrics-line.active → #ffffff (16.48).
  Palette: --warning #c8980c → #735a00 (2.37 → 5.91 on bg-app),
  --text-muted #4870a8 → #3f6aa0 (4.23 → 4.65 on bg-card).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:28:45 +02:00
Psychotoxical b5751c2918 docs(readme): fold Arch/AUR into the Linux install section
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:27:24 +02:00
Psychotoxical 8c050ad297 docs(readme): add AppImage to Linux install options
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:25:56 +02:00
cucadmuh e2100aedc4 Update README.md (#210) 2026-04-19 01:21:20 +02:00
Psychotoxical aa69f360eb docs(readme): Cachix badge, NixOS feature/install, drop obsolete macOS xattr
- Add Cachix badge to the top row linking to psysonic.cachix.org
- New NixOS/flakes feature bullet and a dedicated install block with
  nix run quickstart, system-config hint, Cachix substituter setup, and
  credit + PR link for @cucadmuh
- macOS install block: drop the xattr Gatekeeper workaround (obsolete
  since v1.40.0 signed + notarized builds); replace with a note on the
  in-app auto-updater

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:09:36 +02:00
Frank Stellmacher ee932db8a9 Update README.md 2026-04-19 01:00:42 +02:00
Frank Stellmacher 461a759f0f Update README.md 2026-04-19 01:00:05 +02:00
Psychotoxical 193a37cf0c docs(about): credit cucadmuh for ArtistCardLocal i18n + NixOS guide
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:51:54 +02:00
cucadmuh 65a46bdd0f docs(nix): add NixOS flake install guide (#209)
Document NixOS/Home Manager installation from the upstream flake,
including Cachix substituter/key configuration, apply commands, and pinning.
Also add a README Linux note linking to the new NixOS guide.
2026-04-19 00:50:42 +02:00
Psychotoxical 0afcc4ab68 feat(mini-player): expandable queue + UX polish
Adds a queue panel that toggles via a new toolbar button and resizes the
mini window between 340×180 (collapsed) and 340×440 (expanded). Tracks
in the queue are clickable — click jumps to that index via a new
mini:jump event handled in the main-window bridge. The current track
auto-scrolls into view when the queue opens.

Resize uses a new resize_mini_player Rust command (more reliable than
JS setSize, which was silently no-op'ing because the
core:window:allow-set-size capability was missing). That capability is
now granted as well.

The mini player hydrates its initial state from the persisted
playerStore, so real content (cover, title, artist, queue) shows as
soon as React mounts instead of "—" while we wait for the first
mini:sync. Dynamic state (isPlaying, progress) still comes from
events.

Window sizing:
- inner_size bumped to 340×180 so the toolbar row isn't clipped
- mini label added to tauri-plugin-window-state denylist so old saved
  sizes don't come back
- show_main_window now also hides the mini — clicking expand no longer
  leaves both windows on screen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:46:28 +02:00
Psychotoxical ab35ef5eb4 chore(release): v1.41.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:43:29 +02:00
Psychotoxical 4ff4ea0df0 fix(i18n): use t('artists.albumCount') in ArtistCardLocal
The album count on local artist cards was rendered with hardcoded German
("Album"/"Alben"). Switched to the existing plural-aware i18n key which
covers all 8 locales (including Russian Slavic plurals).

Co-Authored-By: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:35:01 +02:00
Psychotoxical cef2db92cb feat(mini-player): floating mini window — early alpha (#162)
A second webview window (label "mini") with a compact player: album art,
title, artist, prev/play/next, progress bar, pin-on-top toggle, expand
back to main, close. Main minimizes on open and restores when the mini
is hidden or closed. Spacebar toggles, arrow keys skip tracks.

Cross-window sync: main window subscribes to playerStore and pushes
`mini:sync` via emitTo on track / play-state change. Audio progress
already broadcasts to all windows via `audio:progress`. Control actions
(prev/next/toggle) come back via `mini:control`; main-window restore
goes through a direct Rust command because WebKitGTK pauses JS in a
minimized webview.

Tiling-WM detection reused from existing `is_tiling_wm()` — always-on-top
is skipped on Hyprland/Sway/i3 since it's ignored there anyway.

Early alpha: no queue expand, no lyrics, no EQ, no drag-snap. Scope kept
deliberately tight for v1; follow-ups planned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:32:20 +02:00
Psychotoxical 72e193cf2c ci(release): explicitly push psysonic closure to Cachix
cachix-action installs a post-build-hook via NIX_USER_CONF_FILES, but
the Determinate Nix daemon reads system nix.conf and never fires the
hook — only two early prep paths ever reached the cache, never the
actual psysonic output. Add an explicit closure push after the build;
Cachix dedupes, so redundancy is cheap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:04:57 +02:00
Psychotoxical 6b3e809d12 feat(device-sync): show album artist in both panels
Adds optional artist field to DeviceSyncSource and renders it inline
next to the album name ("Album · Artist") in the on-device list.
BrowserRow (left panel) uses the same inline format so albums in search,
random picks and under expanded artists all read consistently. Playlists
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:58:51 +02:00
Psychotoxical 2e5a34178b feat(ux): collapse Albums sort buttons into a dropdown
Two sort buttons (A–Z Album / A–Z Artist) become one SortDropdown —
same portal popover pattern as the year and genre filters. Button
shows the current choice with an up/down arrow icon. Generic
component, reusable for other pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:52:38 +02:00
Psychotoxical 25537f2743 fix(fullscreen): lyrics menu toggle + readable panel
Clicking the mic button now toggles the lyrics settings panel instead
of outside-handler closing it and click re-opening it — trigger ref is
excluded from the outside-click check.

Panel is now a solid surface (no backdrop-blur, near-opaque background,
higher-contrast button text) so settings stay readable over the busy
fullscreen background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:48:21 +02:00
Psychotoxical 8b7bce5b85 feat(ux): year filter as portal popover
Replaces the inline From/To number inputs in the Albums header with a
single button that opens a popover — same pattern as the genre filter.
Button shows the active range (e.g. 2020–2024) with accent styling.
Header is now a homogeneous row of buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:43:56 +02:00
Psychotoxical 89e8f43add feat(albums): compilation filter toggle in All Albums
Tri-state button (all / only compilations / hide compilations) in the
Albums page header, using the OpenSubsonic isCompilation tag from
Navidrome. Client-side filter via useMemo, no extra server calls.
Closes #65.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:33:45 +02:00
Psychotoxical da38b411b0 feat(ux): redesign genre filter as portal popover
Trigger button with count badge, portal-rendered popover with search
input and full scrollable checkbox list (no 60-item cap). Selected
genres sort to the top. Replaces the inline tagbox that ate header
space and cut off the alphabetical list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:32 +02:00
Psychotoxical 4f2c313bb7 feat(ux): sticky header on Albums, NewReleases, Artists
Header with search/sort/genre/year controls now pins to top while
scrolling, so filters stay reachable. Nested .content-body (App.tsx
wraps routes) was becoming the sticky anchor and swallowing the effect —
fixed with .content-body .content-body { overflow: visible }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:22 +02:00
Psychotoxical c96eb0a805 feat(favorites): add genre column + Top Favorite Artists row
Genre column (toggleable via column picker) and a horizontally scrolling
Top Favorite Artists section between Radio Stations and Songs, aggregated
from starred tracks. Closes #87.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:14 +02:00
Psychotoxical 66c0ecbc1f fix(windows): tray double-click flicker + GPU use when minimized
1. Tray double-click (reported by @cucadmuh's brother-in-law):
   Windows fires a Click event on *both* halves of a double-click, so
   our left-click handler toggled visibility twice — the window popped
   up and immediately vanished. Switch the Windows branch to the
   `TrayIconEvent::DoubleClick` variant (Windows-only in tray-icon);
   other platforms keep the Click-on-Up behaviour. Matches the standard
   Windows tray convention (Discord, Telegram, etc).

2. GPU use while minimized:
   WebView2 on Windows keeps compositing infinite CSS animations
   (mesh-aura-a/b, portrait-drift, eq-bounce, track-pulse, led-pulse …)
   even when the window is minimized — steady visible GPU load on
   systems that should be idle. App.tsx now toggles a
   `data-app-hidden="true"` attribute on <html> on `visibilitychange`,
   and a single CSS rule pauses every `animation` at once via
   `animation-play-state: paused !important`. Zero cost when visible,
   catches all current and future infinite animations without
   per-element listeners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 20:20:24 +02:00
Psychotoxical 225609e93c fix(i18n): add common.close to en + de locales
The device-sync migration modal uses t('common.close') for its dismiss
button, but the key lived only inside scoped namespaces (where it had
different translations like "Verstanden" / "Got it"). Add the
straightforward "Schließen" / "Close" under common so the migration
modal shows the right label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:54:51 +02:00
Frank Stellmacher 4773f1ca8c Update CHANGELOG.md 2026-04-18 19:51:03 +02:00
Psychotoxical 129cd3ea23 chore(aur): bump pkgver to 1.40.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:43:51 +02:00
Psychotoxical 98352bb656 chore(release): v1.40.0
Major release consolidating several months of work into a single coherent
version. The 1.34.x patch series accumulated many small features; 1.40.0
packages them up and signals the infrastructure milestone (macOS signing
+ notarization + auto-updater) clearly.

Highlights (see CHANGELOG for the full rundown):
- macOS builds are signed with a Developer ID certificate and notarized
  by Apple — no more Gatekeeper "unidentified developer" dialog
- In-app auto-update on macOS via the Tauri Updater plugin; polished
  modal with trust badges, restart countdown, and state-dependent buttons
- Linux WebKitGTK wheel scroll toggle (PR #207 by cucadmuh)
- Device Sync: user-configurable filename template replaced with a
  fixed cross-OS scheme; playlists now live in their own self-contained
  folders with sibling-referencing .m3u8; one-shot migration tool for
  existing sticks
- WCAG contrast audits for middle-earth and nucleo themes
- Contributors list in Settings → About updated (PRs #205, #206, #207)

Windows signing + Windows auto-updater are the remaining gap; 2.0.0 is
planned once both are active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:41:16 +02:00
Psychotoxical a2f880da0d feat(device-sync): fixed cross-OS naming scheme + playlist folders
Replace the user-configurable filename template with a fixed scheme so
syncing the same library across Linux/Windows/macOS no longer re-downloads
files just because the template string differs by OS.

Track layout on the device:
  {AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}
  Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}
  Playlists/{PlaylistName}/{PlaylistName}.m3u8

Playlist tracks go into a self-contained folder with a sibling-referencing
Extended M3U, instead of being scattered across the album tree with an
external .m3u8 — the stick stays tidy even for 50-artist playlists.

Rust (lib.rs):
- TrackSyncInfo: drop disc_number/year (not used in fixed schema), add
  album_artist, duration, playlist_name, playlist_index
- sanitize_path_component strengthens empty-result handling via new
  sanitize_or() which falls back to "Unknown Artist/Album/Title"
- build_track_path() dispatches on playlist context — playlist tracks
  get the "Playlists/{Name}/…" shape, album tracks the traditional tree
- albumArtist resolution: the server's albumArtist tag, falling back to
  the track artist (empty-string treated as missing)
- calculate_sync_payload: dedup key is now (source_id, track_id) instead
  of track_id alone, so a track appearing in both an album and a playlist
  ends up on the device in both locations; playlist context is embedded
  into the response JSON as _playlistName / _playlistIndex so the follow-
  up sync_batch_to_device call can route each track correctly
- write_playlist_m3u8: rewritten — writes into the playlist folder with
  sibling filenames instead of a top-level .m3u8 with ../relative paths
- New rename_device_files command: atomic renames (per-entry result,
  skip-if-exists, skip-if-source-missing) plus empty-dir cleanup. Used
  by the migration flow
- write_device_manifest: v2 format, drops filenameTemplate field
- Drop the `template` parameter from sync_track_to_device,
  compute_sync_paths, calculate_sync_payload, sync_batch_to_device

Frontend:
- DeviceSyncStore: drop filenameTemplate from state and persist
- DeviceSync.tsx: strip template editor UI (presets, token buttons, live
  preview); replace with a compact read-only schema info card
- trackToSyncInfo(track, url, playlistCtx?): optional playlist context
  parameter. Tracks coming back from calculate_sync_payload with embedded
  _playlistName/_playlistIndex fall through via the same parameter
- compute_sync_paths / write_playlist_m3u8 / delete paths now pass the
  playlist context per-source so status checks + deletions hit the right
  files (album tree vs playlist folder)
- Album-Artist fallback: frontend falls back to track artist when the
  server has no albumArtist tag (empty strings treated as missing) —
  "Unknown Artist" is only used as a last-resort placeholder
- New migration flow: "Reorganize existing files…" button opens a modal
  that reads the legacy filenameTemplate from the v1 manifest, computes
  per-track (old, new) rename pairs, detects collisions (two old files
  mapping onto one new path), and executes via rename_device_files.
  Playlist-only tracks are left for the next sync to re-download into
  the new playlist folder
- Small JS-side applyLegacyTemplate() helper: only used by the migration
  preview so we can diff old paths against the current files on disk
- CSS: device-sync-schema-section / -code / -hint, migrate-modal +
  summary + warning + result layout

i18n (en + de):
- Remove dead keys: filenameTemplate, templatePreview, templateHint,
  templatePresetStandard/MultiDisc/AltFolder, tokenSlashHint
- Add schemaLabel, schemaHint, migrateButton/Title/Tooltip/Loading/
  NothingToDo/NoTemplate/FilesToRename/Unchanged/Collisions/
  PreviewNote/Executing/Success/Failed/ShowErrors/Start

Other locales (fr/nl/zh/nb/ru/es) fall through to the English defaultValue
until translated in a follow-up pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:30:25 +02:00
Psychotoxical 95f714654d ci(release): re-enable Windows, Linux and verify-nix jobs
The macOS updater pipeline is stable now, so restore full-platform
builds + the Nix cache refresh on every release. These were disabled
temporarily during v1.34.14–v1.34.23 iteration to cut turnaround time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:46:45 +02:00
Psychotoxical e201bc630c feat(updater): polish macOS update modal with countdown, trust badges, clean button states
- Footer buttons are now state-dependent: Skip / Remind later only show
  during idle; the install button no longer leaves a gap when it
  disappears, and "Remind later" stops shifting between states
- Post-install on macOS shows a 3-second visible restart countdown with
  a "Restart now" button instead of triggering an invisible relaunch
  that sometimes didn't actually fire
- macOS idle state now explains the in-place update model (no DMG
  download) and shows trust badges: "Notarized by Apple" + "Signature
  verified" as an at-a-glance trust signal
- Download state during install hides all secondary buttons so the
  progress bar stands alone
- New i18n keys in en/de; fr/nl/zh/nb/ru/es fall back to the English
  defaultValue until translated in a follow-up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:44:50 +02:00
Psychotoxical 36f0ef79fe chore(release): v1.34.23 — update-target for v1.34.22 auto-update test
No functional changes. Throwaway build to serve as the "latest" release
that v1.34.22 will find, download, verify, and install via the Tauri
Updater. Will be deleted once the updater round-trip is confirmed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:18:33 +02:00
Psychotoxical 939ed3d412 chore(release): v1.34.22 — fix truncated updater pubkey
A character was lost when the base64-encoded minisign public key was
transcribed into tauri.conf.json, producing a 41-byte decoded key
instead of the required 42 bytes. Every release built against that key
(v1.34.15 through v1.34.21) rejects any update manifest signature with
"Invalid encoding in minisign data". Replaced with the correct pubkey
read directly from ~/.tauri/psysonic-updater.key.pub.

v1.34.19 installed manually for testing cannot receive auto-updates
because the broken pubkey is baked into its binary; a fresh v1.34.22
install is required as the base for the updater test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:04:51 +02:00
Psychotoxical bddfd45086 chore(release): v1.34.21 — macOS updater UI fix + second test build
- AppUpdater.tsx: macOS now has a dedicated branch in the update modal —
  no architecture-specific DMG asset shown (the Tauri Updater picks the
  right platform from latest.json), clearer wording ("Downloads, verifies
  and installs automatically"), and the primary button reads "Install
  now" instead of "Download". Strings use defaultValue so locales without
  the key fall back to English until translations catch up.
- Test release for the updater pipeline; CHANGELOG entry asks users to
  ignore it. Will be deleted after the test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:05:02 +02:00
Psychotoxical 48c10e5619 chore(release): v1.34.20 — test build for auto-updater pipeline
Throwaway release to validate the macOS Tauri Updater end-to-end:
v1.34.19 (installed manually) → check() → latest.json → download +
install → relaunch. Contains no actual changes. CHANGELOG entry asks
users to ignore it; will be deleted after the test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:46:03 +02:00
Psychotoxical 216e6c957f ci(release): re-sign updater tarball after tauri-action's repack
tauri-action always re-packs Psysonic.app into .app.tar.gz after tauri
CLI finishes (regardless of includeUpdaterJson), so the .sig that tauri
CLI produces never matches the final tarball — and the sig tauri-action
was supposed to produce silently goes missing ("Signature not found for
the updater JSON. Skipping upload...").

Rather than fight the repack, sign the repacked tarball ourselves in a
follow-up step using `tauri signer sign` against the same private key
(from TAURI_SIGNING_PRIVATE_KEY), then upload the fresh .sig. tauri-
action still handles the DMG + .app.tar.gz upload; we only add the .sig.

Also removes an unused `std::rc::Rc` import in the vendored cpal patch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:31:17 +02:00
Psychotoxical 425addffa0 ci(release): disable tauri-action updater repack, upload .sig ourselves
Root cause: tauri-action runs a second "Packaging" pass after tauri CLI
finishes, which rewrites src-tauri/target/<target>/release/bundle/macos/
Psysonic.app.tar.gz with different bytes. The .sig produced by tauri CLI
no longer matches the new hash, so tauri-action silently deletes it —
leaving the directory with only .app and .app.tar.gz (no .sig for our
manifest generator to consume).

Fix: pass `includeUpdaterJson: false` to tauri-action so it skips the
repack + updater JSON upload entirely, then copy both the .app.tar.gz
and .app.tar.gz.sig produced by tauri CLI into the workspace root with
the expected asset names and `gh release upload` them.

Also disables build-linux and the Windows matrix entry during testing
to cut iteration time roughly in half.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:19:33 +02:00
Psychotoxical 43df830960 ci(release): debug .sig discovery, disable verify-nix during testing
- Replace the glob find for the .sig file with an explicit target-based
  path ($matrix → src-tauri/target/<target>-apple-darwin/release/bundle/
  macos/Psysonic.app.tar.gz.sig) and dump directory listings so we can
  see what tauri-action actually leaves behind if the path is wrong
- Skip verify-nix while we iterate fast on signing + updater (its auto-
  commits of flake.lock / npmDepsHash cause rebase friction on every
  release)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:08:56 +02:00
200 changed files with 39462 additions and 4195 deletions
+15
View File
@@ -0,0 +1,15 @@
name: ci-main
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci-ok:
runs-on: ubuntu-latest
steps:
- name: ci sentinel
run: echo "main CI sentinel is green"
+33
View File
@@ -0,0 +1,33 @@
name: Next Channel
# Pushes made with the default GITHUB_TOKEN from another workflow (e.g. Promote main to next)
# do not trigger push workflows — GitHub blocks recursive runs. Use workflow_run below.
#
# workflow_dispatch: set "Use workflow from branch" to `main` so reusable-channel-publish.yml
# is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below.
on:
push:
branches: [next]
# Merging auto PR "chore(nix): refresh lock …" only touches these paths — do not
# re-run the full channel (would loop: publish → verify-nix PR → merge → push → publish).
paths-ignore:
- "flake.lock"
- "nix/**"
workflow_dispatch:
workflow_run:
workflows: ["Promote main to next"]
types: [completed]
jobs:
publish-next:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
uses: ./.github/workflows/reusable-channel-publish.yml
with:
channel: next
# Always build from channel branch; do not tie checkout to workflow_dispatch UI branch.
source_ref: next
target_branch: next
prerelease: true
draft_release: true
verify_nix: true
secrets: inherit
@@ -0,0 +1,70 @@
# Keeps `nix/upstream-sources.json` (`npmDepsHash`) in sync with `package-lock.json`.
# Runs in CI with Nix — contributors do not need Nix locally.
#
# Skips fork PRs (cannot push to the contributor branch); after merge to main,
# next, or release, the push workflow updates the hash on that branch if needed.
name: Sync Nix npmDepsHash
on:
push:
branches: [main, next, release]
paths:
- package-lock.json
- package.json
- nix/upstream-sources.json
pull_request:
paths:
- package-lock.json
- package.json
- nix/upstream-sources.json
workflow_dispatch:
concurrency:
group: nix-npm-hash-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-24.04
permissions:
contents: write
# Fork PRs: token cannot push to the fork — merge to main will run the push workflow instead.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v15
- name: Recompute npmDepsHash from package-lock.json
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "Computed npmDepsHash: $HASH"
TMP="$(mktemp)"
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > "$TMP"
mv "$TMP" nix/upstream-sources.json
- name: Commit and push if changed
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add nix/upstream-sources.json
if git diff --cached --quiet; then
echo "npmDepsHash already matches package-lock.json — nothing to commit."
exit 0
fi
git commit -m "chore(nix): sync npmDepsHash with package-lock.json"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
git push origin "HEAD:${PR_HEAD_REF}"
else
git push origin "HEAD:${{ github.ref_name }}"
fi
@@ -0,0 +1,66 @@
name: Promote main to next
on:
workflow_dispatch:
jobs:
validate-main:
uses: ./.github/workflows/validate-main-green-ci.yml
with:
branch: main
required_contexts: ci-ok|ci-main / ci-ok
promote:
needs: validate-main
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: next
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: fast-forward next to main
run: |
set -euo pipefail
git fetch origin main next
# Reset channel branch to main snapshot, then create a fresh RC bump commit.
git reset --hard origin/main
- name: bump package version to next RC
run: |
set -euo pipefail
git fetch --tags origin
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
BASE_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
MAX_RC="$(git tag -l "app-v${BASE_VERSION}-rc.*" | sed -E 's/.*-rc\.([0-9]+)$/\1/' | sort -n | tail -n1)"
if [[ -z "$MAX_RC" ]]; then
NEXT_RC=1
else
NEXT_RC=$((MAX_RC + 1))
fi
TARGET_VERSION="${BASE_VERSION}-rc.${NEXT_RC}"
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
echo "package.json already uses $TARGET_VERSION"
exit 0
fi
npm version --no-git-tag-version "$TARGET_VERSION"
- name: sync Tauri version from package.json
run: node scripts/sync-tauri-version-from-package.js
- name: commit RC version bump
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No version bump changes to commit."
exit 0
fi
NEW_VERSION="$(node -p 'require("./package.json").version')"
git commit -m "chore(release): bump next channel to ${NEW_VERSION}"
- name: push next
run: git push --force-with-lease origin HEAD:next
@@ -0,0 +1,51 @@
name: Promote next to release
on:
workflow_dispatch:
jobs:
promote:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: release
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: fast-forward release to next
run: |
set -euo pipefail
git fetch origin next release
# Reset stable channel to next snapshot, then finalize version on top.
git reset --hard origin/next
- name: finalize package version for release
run: |
set -euo pipefail
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
FINAL_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
if [[ "$CURRENT_VERSION" == "$FINAL_VERSION" ]]; then
echo "package.json is already final: $FINAL_VERSION"
exit 0
fi
npm version --no-git-tag-version "$FINAL_VERSION"
- name: sync Tauri version from package.json
run: node scripts/sync-tauri-version-from-package.js
- name: commit final version bump
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No finalization changes to commit."
exit 0
fi
NEW_VERSION="$(node -p 'require("./package.json").version')"
git commit -m "chore(release): finalize release version ${NEW_VERSION}"
- name: push release
run: git push --force-with-lease origin HEAD:release
+24 -280
View File
@@ -1,285 +1,29 @@
name: Release
name: Release Channel
# Same GITHUB_TOKEN push limitation as Next Channel (see next.yml).
#
# workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`.
on:
push:
tags:
- 'v*'
branches: [release]
# Same nix-refresh loop as next.yml — ignore lock-only merges from verify-nix PRs.
paths-ignore:
- "flake.lock"
- "nix/**"
workflow_dispatch:
workflow_run:
workflows: ["Promote next to release"]
types: [completed]
jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: '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;
}
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body,
draft: true,
prerelease: false
});
return data.id;
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: '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
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> $GITHUB_ENV
- 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 }}
# Apple signing + notarization (macOS runner only — ignored on Windows)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV
# Tauri Updater signing — produces .sig files alongside the update bundles
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: upload updater signature (macOS only)
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
SIG=$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz.sig' | head -1)
if [ -z "$SIG" ]; then
echo "::error::No .sig file found under src-tauri/target/*/release/bundle/macos/"
exit 1
fi
echo "Found signature: $SIG"
if echo "$SIG" | grep -q 'aarch64-apple-darwin'; then
DST="Psysonic_aarch64.app.tar.gz.sig"
else
DST="Psysonic_x64.app.tar.gz.sig"
fi
cp "$SIG" "$DST"
gh release upload "app-v${VERSION}" "$DST" --clobber
generate-manifest:
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: '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
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
# Verifies that `nix build .#psysonic` still works against the current source,
# refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
# (nixpkgs pin), and pushes the resulting store paths to the public Cachix
# binary cache so end users can `nix profile install github:Psychotoxical/psysonic`
# without having to compile locally.
#
# The refreshed lock/hash files are committed back to `main` when they change.
verify-nix:
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
# Full history so we can push the auto-commit back to the default branch.
fetch-depth: 0
# Checkout main, not the tag — we want to push lock/hash refreshes to
# the moving branch, not the immutable tag ref.
ref: main
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
# cachix-action with no signingKey = Cachix-managed signing (Cachix signs
# server-side). The action watches the nix store during subsequent build
# steps and uploads new paths automatically.
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
cat nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
- name: commit + push refreshed lock and hash (if changed)
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add flake.lock nix/upstream-sources.json
if git diff --cached --quiet; then
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
exit 0
fi
VERSION="${{ needs.create-release.outputs.package_version }}"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin HEAD:main
publish-release:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
uses: ./.github/workflows/reusable-channel-publish.yml
with:
channel: release
source_ref: release
target_branch: release
prerelease: false
draft_release: true
verify_nix: true
secrets: inherit
@@ -0,0 +1,433 @@
name: Reusable Channel Publish
on:
workflow_call:
inputs:
channel:
description: "Delivery channel name (release/next)"
required: true
type: string
source_ref:
description: "Git ref to build from"
required: true
type: string
target_branch:
description: "Branch that receives nix refresh PRs"
required: true
type: string
prerelease:
description: "Mark GitHub release as prerelease"
required: true
type: boolean
draft_release:
description: "Create release as draft"
required: true
type: boolean
verify_nix:
description: "Run verify-nix job"
required: false
default: true
type: boolean
jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
release_tag: ${{ steps.tag.outputs.value }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: get version
id: get-version
run: |
set -euo pipefail
V="$(node -p 'require("./package.json").version')"
# Never publish with a missing/non-semver-ish version — empty becomes tag "app-v" on GitHub and breaks manifests.
if [ -z "$V" ]; then
echo "::error::package.json version is empty"
exit 1
fi
if ! printf '%s' "$V" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::package.json version must start with semver X.Y.Z... (got '$V')"
exit 1
fi
delim="PKGVER_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}_$(openssl rand -hex 8)"
{
echo "version<<$delim"
printf '%s\n' "$V"
echo "$delim"
} >> "$GITHUB_OUTPUT"
- name: compute release tag
id: tag
env:
VERSION: ${{ steps.get-version.outputs.version }}
run: |
set -euo pipefail
if [ -z "${VERSION:-}" ]; then
echo "::error::release tag would be 'app-v' (empty version output) — aborting"
exit 1
fi
TAG="app-v${VERSION}"
if [ "$TAG" = "app-v" ]; then
echo "::error::refuse to create invalid tag 'app-v'"
exit 1
fi
echo "value=$TAG" >> "$GITHUB_OUTPUT"
- name: extract changelog
id: changelog
run: |
VERSION="${{ steps.get-version.outputs.version }}"
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
if [ -z "$BODY" ]; then
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
fi
fi
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: create or update release
id: create-release
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
RELEASE_TAG: ${{ steps.tag.outputs.value }}
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
IS_PRERELEASE: ${{ inputs.prerelease }}
IS_DRAFT: ${{ inputs.draft_release }}
with:
script: |
const tag = process.env.RELEASE_TAG;
const body = process.env.CHANGELOG_BODY || "See the assets to download this version and install.";
const prerelease = process.env.IS_PRERELEASE === "true";
const draft = process.env.IS_DRAFT === "true";
const version = process.env.PACKAGE_VERSION;
const name = `Psysonic v${version}`;
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,
name,
draft,
prerelease,
});
return data.id;
} catch (e) {
if (e.status !== 404) throw e;
}
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name,
body,
draft,
prerelease,
});
return data.id;
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
- platform: "windows-latest"
args: "--bundles nsis"
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "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
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> "$GITHUB_ENV"
- 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 }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: re-sign updater bundle + upload .sig (macOS only)
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -e
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
TARGET_ARG='${{ matrix.settings.args }}'
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
TARGET="aarch64-apple-darwin"
ARCH="aarch64"
else
TARGET="x86_64-apple-darwin"
ARCH="x64"
fi
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "::error::Expected tarball missing: $TARBALL"
ls -la "$(dirname "$TARBALL")" || true
exit 1
fi
npx @tauri-apps/cli signer sign "$TARBALL"
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
generate-manifest:
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
gh release upload "$RELEASE_TAG" latest.json --clobber
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "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
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "$RELEASE_TAG" --clobber
verify-nix:
if: ${{ inputs.verify_nix }}
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.target_branch }}
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
nix path-info --recursive .#psysonic | cachix push psysonic
- name: open + auto-merge PR with refreshed lock and hash (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add flake.lock nix/upstream-sources.json
if git diff --cached --quiet; then
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
exit 0
fi
VERSION="${{ needs.create-release.outputs.package_version }}"
BRANCH="chore/nix-lock-refresh-${{ inputs.target_branch }}-v${VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--base "${{ inputs.target_branch }}" \
--head "$BRANCH" \
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
bump-main-to-next-dev:
if: ${{ inputs.channel == 'release' }}
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: main
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: compute next dev version
id: next-dev
env:
RELEASE_VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
set -euo pipefail
NEXT_DEV="$(node -e 'const v=process.env.RELEASE_VERSION; const m=v.match(/^(\d+)\.(\d+)\.(\d+)/); if(!m){throw new Error(`Invalid release version: ${v}`)}; const major=Number(m[1]); const minor=Number(m[2]) + 1; process.stdout.write(`${major}.${minor}.0-dev`)')"
echo "value=$NEXT_DEV" >> "$GITHUB_OUTPUT"
- name: bump package version in main
run: |
set -euo pipefail
TARGET_VERSION="${{ steps.next-dev.outputs.value }}"
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
SHOULD_BUMP="$(node -e '
const parse = (v) => {
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
if (!m) throw new Error(`Invalid semver: ${v}`);
const pre = m[4] ?? "";
const preRank = pre === "" ? 3 : pre.startsWith("rc.") ? 2 : pre === "dev" ? 1 : 0;
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), preRank };
};
const a = parse(process.argv[1]); // current
const b = parse(process.argv[2]); // target
const keys = ["major", "minor", "patch", "preRank"];
for (const k of keys) {
if (b[k] > a[k]) { process.stdout.write("true"); process.exit(0); }
if (b[k] < a[k]) { process.stdout.write("false"); process.exit(0); }
}
process.stdout.write("false");
' "$CURRENT_VERSION" "$TARGET_VERSION")"
if [[ "$SHOULD_BUMP" != "true" ]]; then
echo "main already at ${CURRENT_VERSION} (target ${TARGET_VERSION} is not newer)"
exit 0
fi
npm version --no-git-tag-version "$TARGET_VERSION"
- name: sync Tauri version from package.json
run: node scripts/sync-tauri-version-from-package.js
- name: open PR with dev bump (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No dev version bump required."
exit 0
fi
VERSION="${{ steps.next-dev.outputs.value }}"
SAFE_VERSION="${VERSION//./-}"
SAFE_VERSION="${SAFE_VERSION//\//-}"
BRANCH="chore/version-bump-main-${SAFE_VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(release): bump main to ${VERSION}"
git push origin "$BRANCH"
gh pr create \
--base main \
--head "$BRANCH" \
--title "chore(release): bump main to ${VERSION}" \
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
@@ -0,0 +1,116 @@
name: Validate Main Green CI
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to validate"
required: false
default: main
type: string
required_contexts:
description: "Comma-separated required checks; use | for alternatives (e.g. ci-ok|ci-main / ci-ok)"
required: false
default: ci-ok|ci-main / ci-ok
type: string
workflow_call:
inputs:
branch:
required: false
default: main
type: string
required_contexts:
required: false
default: ci-ok|ci-main / ci-ok
type: string
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
checks: read
steps:
- name: validate required checks
uses: actions/github-script@v7
env:
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
REQUIRED_CONTEXTS: ${{ inputs.required_contexts || github.event.inputs.required_contexts || 'ci-ok|ci-main / ci-ok' }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const branch = process.env.TARGET_BRANCH || "main";
const groups = String(process.env.REQUIRED_CONTEXTS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((group) =>
group
.split("|")
.map((s) => s.trim())
.filter(Boolean)
);
const runId = String(context.runId);
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
const sha = branchResp.data.commit.sha;
core.info(`Validating checks for ${branch} @ ${sha}`);
const checksAll = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
const checks = checksAll.filter((c) => !(c.details_url || "").includes(`/actions/runs/${runId}/`));
const newestByName = new Map();
for (const c of checks) {
const key = c.name;
const prev = newestByName.get(key);
if (!prev) {
newestByName.set(key, c);
continue;
}
const prevTime = Date.parse(prev.started_at || prev.completed_at || "") || 0;
const curTime = Date.parse(c.started_at || c.completed_at || "") || 0;
if (curTime >= prevTime) {
newestByName.set(key, c);
}
}
const failures = [];
for (const alternatives of groups) {
let ok = false;
const altNotes = [];
for (const name of alternatives) {
const latest = newestByName.get(name);
if (!latest) {
altNotes.push(`${name}: missing`);
continue;
}
if (latest.status !== "completed") {
altNotes.push(`${name}: status=${latest.status}, conclusion=${latest.conclusion}`);
continue;
}
if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
ok = true;
break;
}
altNotes.push(`${name}: conclusion=${latest.conclusion}`);
}
if (!ok) {
failures.push(`(${alternatives.join(" | ")}): none green — ${altNotes.join("; ")}`);
}
}
if (failures.length > 0) {
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
return;
}
const summary = groups
.map((alts) => (alts.length > 1 ? `(${alts.join(" | ")})` : alts[0]))
.join(", ");
core.info(`All required checks for ${branch} are green (${summary}).`);
+584 -10
View File
@@ -5,39 +5,613 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> ** Note for Windows users:** This is one of the last releases with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
> **🛡 A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
>
> **🎉 macOS users:** Starting with **v1.34.15**, Psysonic can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Update" when the toast appears.
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
>
> **🎉 macOS users:** Starting with **v1.40.0**, Psysonic is signed + notarized and can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Install now" when the update notification appears.
>
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
## [1.34.16] - 2026-04-18
### Fixed
- **CI — Updater signature upload** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: tauri-action on macOS produces the `.app.tar.gz.sig` minisign signature locally but does not upload it as a release asset for cross-target builds, which caused the `latest.json` manifest generator to fail (no signature to embed). An explicit post-build step now finds the `.sig` under `src-tauri/target/*/release/bundle/macos/` and uploads it with the expected filename (`Psysonic_aarch64.app.tar.gz.sig` / `Psysonic_x64.app.tar.gz.sig`).
## [1.44.0] - 2026-04-29
## [1.34.15] - 2026-04-18
## Highlights
- **Orbit Listening Sessions** — synchronized multi-user listening for Psysonic users, including host/guest roles, queue mirroring, track suggestions, participant strip, host-presence handling and full in-app help.
- **Loudness Normalization** — EBU R128 / LUFS analysis with persistent cache, configurable target level and improved waveform data from the same analysis pass.
- **Now Playing Dashboard** — a richer, customizable dashboard with draggable and resizable cards, artist context, album tracklist, credits, tour dates and discography.
- **Tracks Hub** — a new full-library track-level view with random discovery, searchable browsing and Navidrome-native sorting.
- **Smart Playlists** — first-class Navidrome smart playlist creation, editing and management directly inside the Playlists page.
- **Share Links / Magic Strings** — share servers, tracks, albums, artists and queues through Psysonic magic strings.
- **Lucky Mix** — instant AudioMuse-powered mixes based on listening history, ratings and similar-song discovery.
- **Settings Overhaul** — cleaner tab structure, accordion sections, in-page search and better grouping of integrations and personalization options.
- **Playlist Suggestion Preview** — audition playlist suggestions with 30-second previews, explicit play-next controls and deliberate add-to-playlist actions.
---
## Added / New Features
### Orbit — Multi-User Listen-Together
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#304](https://github.com/Psychotoxical/psysonic/pull/304)**
Orbit introduces synchronized listening sessions for multiple Psysonic users. A host starts a session, shares a magic-string invite, and guests can join to mirror the host's queue, current track and playback position.
Guests can suggest individual tracks instead of replacing the full queue. Hosts can approve suggestions manually or enable auto-approve. Orbit also includes a live participant strip in the queue, per-guest mute, host-presence detection, automatic handling of stalled sessions and a detailed 9-section in-app help modal.
Orbit is integrated across Psysonic's interactive song surfaces, including Tracks Hub, Albums, Playlists, Random Mix, Favorites, Artist pages, Search and Advanced Search. When a session is active, song actions are routed through Orbit so guests suggest tracks and hosts enqueue them correctly.
The feature includes full i18n coverage across all supported locales and end-to-end documentation in `ORBIT.md`.
> Available in **1.44 RC1**.
### Loudness Normalization — EBU R128 / LUFS
**By [@cucadmuh](https://github.com/cucadmuh) and [@Psychotoxical](https://github.com/Psychotoxical), PRs [#315](https://github.com/Psychotoxical/psysonic/pull/315), [#317](https://github.com/Psychotoxical/psysonic/pull/317), [#326](https://github.com/Psychotoxical/psysonic/pull/326), [#333](https://github.com/Psychotoxical/psysonic/pull/333)**
Psysonic now supports integrated-loudness analysis using LUFS and applies per-track gain so material mastered at different levels lines up more consistently at a user-chosen target loudness.
Analysis results are cached in SQLite, so cold-cache analysis happens once per track and later playback reuses the stored measurement without repeating the heavy analysis work. The available LUFS targets are `-16`, `-14`, `-12` and `-10 LUFS`, with `-12 LUFS` as the default.
Normalization now lives in one section under Settings → Audio → Normalization, with Off, ReplayGain and LUFS as mutually exclusive modes.
Until a track has a stored LUFS measurement, Psysonic applies pre-analysis attenuation using a `-14 LUFS` reference calibration in storage, while the active target is reflected as an effective dB value in both the audio engine and the UI. Legacy saved values are migrated on rehydrate.
Follow-up work stabilized the LUFS analysis loop, serialized CPU-heavy seed and analysis work, kept queue target, pre-trim and ReplayGain IPC in sync when the target or pre-analysis state changes, including reseeding when analysis was cleared but waveform cache data still existed, and refined queue/settings behavior and copy.
The same analysis path now also provides richer waveform data through mixed mean/peak waveform bins without requiring a second full decode.
### Now Playing — Customizable Info Dashboard
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#266](https://github.com/Psychotoxical/psysonic/pull/266), [#267](https://github.com/Psychotoxical/psysonic/pull/267)**
The Now Playing page has been rebuilt from a flat card list into a two-column info dashboard focused on context, metadata and discovery.
The page now includes a richer cover hero, release-age information, technical badges, Last.fm love state, lyrics toggle, star rating and per-user play count. The dashboard can show a sliding-window album tracklist, top songs by the same artist, OpenSubsonic credits, artist biography, discography and Bandsintown tour dates.
Cards are draggable and resizable, and the layout is persisted per user. The implementation also includes memoized cards and TTL caches for song metadata, artist info, top songs, discography, Bandsintown and Last.fm data to avoid unnecessary refetching during same-artist playback.
### Tracks — Full Library Hub Page
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#300](https://github.com/Psychotoxical/psysonic/pull/300), closes [#299](https://github.com/Psychotoxical/psysonic/issues/299)**
Psysonic now has a dedicated Tracks page between All Albums and Build a Mix. Instead of browsing only through albums, users can explore the library directly at track level.
The page includes a rerollable “Track of the moment” hero, a random-pick rail and a virtualized, paginated browse list with search. On Navidrome, browsing uses the native `/api/song?_sort=title&_order=ASC` endpoint for proper AZ sorting, with a graceful fallback for non-Navidrome servers.
A new `enqueueAndPlay()` helper appends a track to the queue and jumps to it instead of replacing the queue.
### Genres — Tag Cloud Refactor
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#311](https://github.com/Psychotoxical/psysonic/pull/311)**
The Genres page has been redesigned as a compact, flowing tag cloud. This replaces the previous large SVG-card grid, which could freeze the WebKitGTK renderer on large libraries.
Genre pills are sized by album count, use deterministic palette colors and open instantly even with hundreds of genres. Pagination is no longer needed.
### Now Playing Info Tab in Queue Panel
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#244](https://github.com/Psychotoxical/psysonic/pull/244)**
The right-side queue panel now includes a third tab next to Queue and Lyrics. The new Info tab shows context for the currently playing track.
It includes an artist card with biography and image from Subsonic `getArtistInfo`, song contributors from OpenSubsonic metadata, and optional Bandsintown tour dates. Bandsintown integration is opt-in and includes privacy information when disabled.
### Discover Songs Rail on Mainstage
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#301](https://github.com/Psychotoxical/psysonic/pull/301)**
The Home page now includes a new Discover Songs rail, surfacing fresh track-level recommendations alongside the existing album-focused sections.
### ReplayGain — Auto Mode
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#242](https://github.com/Psychotoxical/psysonic/pull/242)**
ReplayGain now has an Auto mode. Psysonic automatically chooses album gain when the current queue is a contiguous album and track gain for shuffled or mixed playback.
This removes the need to manually switch normalization behavior between full-album listening and mixed queues.
### Settings — Refactor, Accordions and Search
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#259](https://github.com/Psychotoxical/psysonic/pull/259), [#263](https://github.com/Psychotoxical/psysonic/pull/263), [#264](https://github.com/Psychotoxical/psysonic/pull/264), closes [#257](https://github.com/Psychotoxical/psysonic/issues/257)**
Settings have been reorganized into clearer thematic tabs: Servers, Library, Audio, Lyrics, Appearance, Personalisation, Integrations, Input, Storage, System and Users.
Tabs are now split into accordion sections for a calmer landing view. A new in-page search can expand matching sections automatically, search across tabs, support keyboard navigation and flash matching results.
Integrations such as Last.fm, Discord, Bandsintown and Now-Playing Share now live together in the Integrations tab. Sidebar, Artist and Home customizers live under Personalisation. Contributors are shown in a dedicated System section.
### Playlists — Suggestion Preview UX
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#365](https://github.com/Psychotoxical/psysonic/pull/365)**
Playlist suggestions introduce a dedicated preview workflow for auditioning recommended songs before adding them to a playlist or sending them to the queue.
Each suggestion includes a 30-second preview action with an animated progress ring. Previews play through a separate HTML5 audio element: the main player pauses during the preview, resumes when the preview ends, and cancels cleanly when playback is controlled manually.
An explicit Play Next button inserts the suggestion after the current queue item and starts playback there. Double-clicking a suggestion row adds it to the playlist, matching the existing plus button, while single-clicking the row intentionally does nothing to avoid accidental actions.
Adding a suggestion preserves the playlist scroll position. The preview path is also built to behave reliably on WebKitGTK and applies LUFS pre-analysis attenuation when loudness normalization is enabled, so previews stay closer to normal playback volume.
The feature includes updated i18n coverage across all supported locales.
### Artist Page — User-Configurable Sections
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#254](https://github.com/Psychotoxical/psysonic/pull/254), closes [#252](https://github.com/Psychotoxical/psysonic/issues/252) from [@bcorporaal](https://github.com/bcorporaal)**
The Artist Detail page now lets users reorder and hide/show individual sections such as Top Songs, Albums, Similar Artists and Bio.
The layout is persisted per user and can be configured from Settings → Personalisation.
### Album Enqueue Actions
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#256](https://github.com/Psychotoxical/psysonic/pull/256), closes [#253](https://github.com/Psychotoxical/psysonic/issues/253) from [@bcorporaal](https://github.com/bcorporaal)**
Album covers now include an Enqueue hover action next to Play. Album context menus and the multi-select toolbar also gained Enqueue actions.
All three actions append to the existing queue instead of replacing it.
### Playlists — Bulk Delete and Duplicate Confirmation
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#290](https://github.com/Psychotoxical/psysonic/pull/290), [#329](https://github.com/Psychotoxical/psysonic/pull/329)**
The Playlists page now includes a bulk-delete action while in selection mode. When adding a song that already exists in a playlist, Psysonic now asks for confirmation instead of silently adding a duplicate.
### Search — Unified SongRow and Paginated Song Results
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#303](https://github.com/Psychotoxical/psysonic/pull/303)**
Search results, Advanced Search and the Tracks Hub now share one `SongRow` component.
Song results in search pages are paginated with infinite scroll instead of being limited to a single capped batch.
### Login — Language Picker
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#328](https://github.com/Psychotoxical/psysonic/pull/328)**
The login page now includes a language picker so first-run users can choose their language before they have a server profile where that preference can be saved.
### Lucky Mix
**By [@cucadmuh](https://github.com/cucadmuh), PRs [#278](https://github.com/Psychotoxical/psysonic/pull/278), [#332](https://github.com/Psychotoxical/psysonic/pull/332)**
Lucky Mix builds an instant queue from listening history, ratings and AudioMuse similar-song batches. It skips low-rated tracks, honors the active library scope and can be cancelled while building.
The feature appears in the sidebar, mobile overlay and Mix landing page when AudioMuse is available. Follow-up work corrected the rating filter against Navidrome's OpenSubsonic rating fields.
### Library Deep Links — `psysonic2-` Share Scheme
**By [@cucadmuh](https://github.com/cucadmuh), PR [#261](https://github.com/Psychotoxical/psysonic/pull/261)**
Psysonic can now share tracks, albums, artists and queues through `psysonic2-` magic strings. Pasting one into the app switches to the matching server and plays or navigates to the shared item.
Queue links resolve tracks in chunks and report how many tracks could be played or skipped when the receiving server is missing items.
### Magic-String Server Invites and Navidrome Admin Sharing
**By [@cucadmuh](https://github.com/cucadmuh), PR [#258](https://github.com/Psychotoxical/psysonic/pull/258)**
Navidrome admins can generate a `psysonic1-` invite string that pre-fills the add-server form for another user. The add-user dialog also validates library access so non-admin users cannot be saved without any libraries.
### Sleep Timer — Circular Ring UI
**By [@cucadmuh](https://github.com/cucadmuh), PR [#272](https://github.com/Psychotoxical/psysonic/pull/272)**
The sleep timer and delayed-start UI now use a circular progress ring around the play/pause button, with an in-button countdown and redesigned timer modal.
The updated UI works across PlayerBar, FullscreenPlayer and MobilePlayerView.
### Queue — Undo/Redo with Hotkeys
**By [@cucadmuh](https://github.com/cucadmuh), PR [#331](https://github.com/Psychotoxical/psysonic/pull/331)**
Queue edits can now be undone and redone with Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z. Queue snapshots include order, current track, playback position, play/pause state and scroll offset.
Restoring a snapshot preserves playback when possible and resyncs the audio engine when needed.
### Sidebar — Long-Press Drag to Reorder
**By [@cucadmuh](https://github.com/cucadmuh), PR [#269](https://github.com/Psychotoxical/psysonic/pull/269)**
Sidebar items can now be reordered by long-press dragging. Dropping an item outside the sidebar hides it, using the same hidden-state model as the Settings customizer.
### Playlists — Navidrome Smart Playlists
**By [@cucadmuh](https://github.com/cucadmuh), PR [#289](https://github.com/Psychotoxical/psysonic/pull/289), proposed by bequbed on Discord**
Navidrome smart playlists are now managed directly inside the Playlists page. Users can create, edit and delete smart playlists using the same flow as regular playlists, with a dedicated rule editor for smart playlist parameters.
Smart playlists have distinct icons and support filters such as genre include/exclude, year ranges, ratings and metadata rules.
### Song Info — Copy Fields via Double-Click
**By [@cucadmuh](https://github.com/cucadmuh), PR [#323](https://github.com/Psychotoxical/psysonic/pull/323)**
Double-clicking any field in the Song Info modal copies that value to the clipboard.
### Mobile UI Overhaul
**By [@kilyabin](https://github.com/kilyabin), PR [#238](https://github.com/Psychotoxical/psysonic/pull/238)**
A broad pass over mobile and narrow-viewport layouts, including the sidebar drawer, player view, queue, search and detail pages.
### Logging — Runtime Levels and Debug Export
**By [@cucadmuh](https://github.com/cucadmuh), PR [#241](https://github.com/Psychotoxical/psysonic/pull/241)**
Settings now allow switching between `info` and `debug` log levels at runtime. Users can also export the current debug log for bug reports.
### CLI — Logs Subcommand
**By [@cucadmuh](https://github.com/cucadmuh), PR [#337](https://github.com/Psychotoxical/psysonic/pull/337)**
The `psysonic` CLI now includes a `logs` subcommand with `tail` and `--follow` support.
---
## Changed / Improved
### Performance Suite
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#245](https://github.com/Psychotoxical/psysonic/pull/245), [#246](https://github.com/Psychotoxical/psysonic/pull/246), [#247](https://github.com/Psychotoxical/psysonic/pull/247), [#248](https://github.com/Psychotoxical/psysonic/pull/248), [#249](https://github.com/Psychotoxical/psysonic/pull/249), [#250](https://github.com/Psychotoxical/psysonic/pull/250), [#251](https://github.com/Psychotoxical/psysonic/pull/251)**
A coordinated performance pass improved several expensive areas of the app:
- Search thumbnails now use `CachedImage` to reduce redundant image loading.
- Device Sync parallelizes `getAlbum` calls when syncing artist sources.
- Album infinite-scroll prefetching starts earlier for smoother scrolling.
- Resolved lyrics are persisted to IndexedDB and survive app restarts.
- Rarely used pages are lazy-loaded and vendor chunks are split.
- Artist list filtering and grouping are memoized.
- Genre sorting and rendering were optimized, later replaced by the new tag-cloud layout.
### Navidrome Admin API Resilience
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#260](https://github.com/Psychotoxical/psysonic/pull/260)**
The Navidrome admin REST client is more resilient against flaky upstream behavior. Psysonic now forces HTTP/1.1 for these calls, requires TLS 1.2+, retries transient errors and shows a cleaner UI retry surface instead of raw error toasts.
### Linux Audio Device Selection
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#288](https://github.com/Psychotoxical/psysonic/pull/288)**
On Linux, Psysonic now prefers `pipewire` and then `pulse` before falling back to the default CPAL device resolution.
This avoids cases where the default device resolves to a null sink on PipeWire-based systems. Explicit user device selection in Settings is still respected.
### Tauri Devtools Behavior
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#307](https://github.com/Psychotoxical/psysonic/pull/307), [#310](https://github.com/Psychotoxical/psysonic/pull/310)**
Devtools no longer auto-open during development and are disabled in production builds. In dev builds, they can still be opened with Ctrl+Shift+I.
### Dependency Updates
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#306](https://github.com/Psychotoxical/psysonic/pull/306)**
Updated `rustls-webpki` and `postcss`.
### Subsonic HTTP User-Agent Alignment
**By [@cucadmuh](https://github.com/cucadmuh), PR [#235](https://github.com/Psychotoxical/psysonic/pull/235)**
Rust-side HTTP requests now send the same User-Agent as the main WebView requests, helping servers, rate limiters and reverse proxies identify Psysonic consistently.
---
## Fixed Since Preview / RC
### Cross-Device Resume Position
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#318](https://github.com/Psychotoxical/psysonic/pull/318)**
Server-side play-queue position could remain at the start of a long track if the app was closed without a seek or track change. Psysonic now flushes queue position through a playback heartbeat, on pause and through shared quit paths.
### Image Cache Blob URL Lifetime
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#313](https://github.com/Psychotoxical/psysonic/pull/313), [#321](https://github.com/Psychotoxical/psysonic/pull/321)**
The shared blob URL cache could revoke an image URL while another component still used it, causing `blob:` load errors. Cover URLs are now reference-counted and only revoked after the last consumer unmounts.
### Queue Scroll Context
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#314](https://github.com/Psychotoxical/psysonic/pull/314)**
Clicking a track in the queue no longer snaps the queue list back to the current now-playing position when the user is browsing elsewhere in the queue.
### Mini Player Saved Position
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#280](https://github.com/Psychotoxical/psysonic/pull/280)**
Saved mini-player coordinates are now checked against the current monitor layout. If the saved monitor no longer exists, the position is discarded instead of opening the mini player off-screen.
### Mini Player Volume Popover
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#279](https://github.com/Psychotoxical/psysonic/pull/279)**
The mini-player volume popover is now portal-rendered so it can no longer be clipped by the mini-player window bounds.
### Gapless Volume Handling
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#277](https://github.com/Psychotoxical/psysonic/pull/277)**
Volume changes prepared for the next gapless track are now deferred until the actual transition, preventing them from affecting the still-playing track.
### Search Context Menu and Live Search Behavior
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#298](https://github.com/Psychotoxical/psysonic/pull/298), [#302](https://github.com/Psychotoxical/psysonic/pull/302)**
Live-search clicks now enqueue and play correctly, right-clicking repositions the existing context menu instead of opening a second one, and artist/album rows in search results now support the expected context menu behavior.
### Server Switch Playback and Home Refresh
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#262](https://github.com/Psychotoxical/psysonic/pull/262), [#291](https://github.com/Psychotoxical/psysonic/pull/291)**
Switching servers no longer tears down playback when the same track is still cached locally. The Home page also refreshes correctly after changing the active server.
### Queue Panel Persistence and LUFS Cleanup
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#336](https://github.com/Psychotoxical/psysonic/pull/336)**
The queue panel's open/closed state is now persisted across restarts. A dead loudness-store binding left over from the LUFS branch was removed.
### Toolbar Icons
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#284](https://github.com/Psychotoxical/psysonic/pull/284), closes [#274](https://github.com/Psychotoxical/psysonic/issues/274)**
The Gapless and Infinite Queue toolbar icons now match their actions correctly.
### Pointer Gesture Performance Regression
**By [@cucadmuh](https://github.com/cucadmuh) and [@Psychotoxical](https://github.com/Psychotoxical), PRs [#281](https://github.com/Psychotoxical/psysonic/pull/281), [#282](https://github.com/Psychotoxical/psysonic/pull/282), [#283](https://github.com/Psychotoxical/psysonic/pull/283)**
A short-lived performance spike caused by overlapping pointer gestures was fixed after an initial patch, revert and clean re-landing.
### pingWithCredentials Diagnostics
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#312](https://github.com/Psychotoxical/psysonic/pull/312)**
Credential ping failures are now logged instead of failing silently, making server/auth issues easier to diagnose from debug logs.
### Fullscreen WebKitGTK Halo
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#239](https://github.com/Psychotoxical/psysonic/pull/239)**
The fullscreen mesh-blob background no longer shows a faint halo ring on WebKitGTK.
### Custom Titlebar Glyphs
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#243](https://github.com/Psychotoxical/psysonic/pull/243)**
The macOS-style traffic-light glyphs in the custom titlebar are now hidden at rest and only appear on hover.
### Discord Rich Presence Diagnostics
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#330](https://github.com/Psychotoxical/psysonic/pull/330)**
Debug-only logging was added for the Discord Rich Presence IPC path to make connection issues easier to inspect.
### Streaming Seek UI Freeze
**By [@cucadmuh](https://github.com/cucadmuh), PR [#236](https://github.com/Psychotoxical/psysonic/pull/236), closes [#218](https://github.com/Psychotoxical/psysonic/issues/218)**
Seeking on streaming tracks no longer blocks the render thread or causes the progress indicator to snap back during the pending seek.
### Windows WebView2 Hidden-Window Activity
**By [@peri4ko](https://github.com/peri4ko), PR [#273](https://github.com/Psychotoxical/psysonic/pull/273), follow-up by [@Psychotoxical](https://github.com/Psychotoxical), PR [#276](https://github.com/Psychotoxical/psysonic/pull/276)**
Hidden Windows WebView2 windows now pause more UI work while hidden, including CSS animations and selected periodic tasks.
### Linux Wayland Drag Ghost
**By [@cucadmuh](https://github.com/cucadmuh), PR [#268](https://github.com/Psychotoxical/psysonic/pull/268)**
Wayland drag operations no longer leave GTK drag proxies or PsyDnD ghost elements behind after drag end or cancel.
### Queue Panel Resize Handle
**By [@cucadmuh](https://github.com/cucadmuh), PR [#324](https://github.com/Psychotoxical/psysonic/pull/324)**
The queue panel resize handle is no longer blocked by the main page scroll hit-test area at certain viewport widths.
### Album Queueing and Smart Playlist Targets
**By [@cucadmuh](https://github.com/cucadmuh), PR [#322](https://github.com/Psychotoxical/psysonic/pull/322)**
Queueing a full album after the current track now behaves reliably. Smart playlists are also filtered out of manual “Add to playlist” target lists.
### Analysis Cache Logging
**By [@cucadmuh](https://github.com/cucadmuh), PR [#320](https://github.com/Psychotoxical/psysonic/pull/320)**
Waveform path logging in the analysis cache now reports the correct LUFS pipeline state.
### UI Overlay and Scroll Fixes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#255](https://github.com/Psychotoxical/psysonic/pull/255)**
Overlay-scrollbar state reporting, column-resizer hit testing and Linux mini-player mouse-wheel scrolling were improved.
### Contributors
- [@cucadmuh](https://github.com/cucadmuh) — Loudness Normalization headline (#315), LUFS stabilisation (#326), Lucky Mix (#278), Queue undo/redo (#331), Sleep Timer ring UI (#272), Library deep links (#261), Magic-string invites (#258), CLI logs subcommand (#337), runtime log levels + debug log export (#241), smart playlists workflow (#289), several streaming + UI fixes.
- [@Psychotoxical](https://github.com/Psychotoxical) — Orbit (#304), Now-Playing dashboard (#266 / #267), Tracks Hub (#300), Genres tag-cloud (#311), Settings refactor (#259), perf suite (#245#251), and most of the cross-device + admin-API hardening work.
- [@peri4ko](https://github.com/peri4ko) — Windows WebView2 idle hooks (#273).
- [@kilyabin](https://github.com/kilyabin) — Mobile UI overhaul (#238).
## [1.43.0] - 2026-04-20
### Added
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Update** in the notification toast downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper warnings, no manual DMG handling. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
- **User Management — admin-gated tab in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the active server is Navidrome and the logged-in user is an admin, Settings gets a new "Users" tab. Lists every user with username, display name, email, last-access timestamp and assigned libraries. Add / edit / delete via Navidrome's native REST API (`/api/user`) using a Bearer token obtained from `/auth/login` — the Subsonic API doesn't expose this, so non-Navidrome servers don't get the tab.
## [1.34.14] - 2026-04-18
- **User Management — per-user library assignment** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#222](https://github.com/Psychotoxical/psysonic/pull/222))*: Mirrors the Navidrome web client. Non-admin users get a checkbox picker showing every library on the server; the picker is hidden for admins (Navidrome auto-grants them access to all libraries). Inline validation prevents saving a non-admin with zero libraries.
- **User Management — last-access timestamp per user** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Each row shows when the user was last active, formatted as a localised relative time (`vor 5 Min.`, `2h ago`, etc.) using `Intl.RelativeTimeFormat`. Tooltip carries the absolute timestamp. Users who have never logged in show "Never".
- **Seekable streaming + instant local playback — first cut** *(by [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh))*: New `RangedHttpSource` + `LocalFileSource` audio backends. Seek operations on remote tracks now issue HTTP `Range` requests instead of restarting the stream from byte 0, and locally cached files start playing instantly without going through the HTTP path at all. WaveformSeek commits the seek on mouseup (not during drag), and progress ticks during a drag are ignored so the playhead doesn't jitter back and forth. **Note:** the underlying seek/buffer behaviour is not fully sorted yet — expect follow-up changes in the next releases as edge cases (slow proxies, partial-content retries, codec-specific quirks) get ironed out.
- **Mini player — queue-style meta block, action toolbar, vertical volume slider** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The mini's right column gets a richer track-info block matching the queue panel's styling. A dedicated action toolbar (love / queue / context menu) sits below the transport. The horizontal volume slider is replaced by a tall vertical one on the right edge for a more compact footprint.
- **Settings — compact spacing pass + row hover affordance** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#223](https://github.com/Psychotoxical/psysonic/pull/223))*: Section margins, card padding and divider spacing all tightened — every Settings tab fits more content per viewport. Each toggle row gains a subtle accent-tinted hover background that bleeds to the card edges so the active row is visually obvious.
- **Floating player bar — toggleable variant** *(by [@kveld9](https://github.com/kveld9), PR [#216](https://github.com/Psychotoxical/psysonic/pull/216))*: Settings → Appearance → "Floating player bar" turns the player bar into a floating, rounded panel that sits above the page content with a margin around all four edges. Off by default. Solid background, works with every theme.
- **Floating player bar — liquid-glass look on macOS and Windows** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the floating bar is enabled, macOS and Windows users get a gentler glass-effect background (subtle blur + tint) on top of @kveld9's solid variant. Linux keeps the solid look — WebKitGTK's `backdrop-filter` cost is too high for an always-visible panel. A new `data-platform` attribute on `<html>` is the generic platform-gate that other CSS can hook into.
- **NVIDIA proprietary driver — DMA-BUF auto-disabled on Linux** *(by [@kveld9](https://github.com/kveld9), PR [#217](https://github.com/Psychotoxical/psysonic/pull/217), refactored by [@Psychotoxical](https://github.com/Psychotoxical))*: Detects the NVIDIA proprietary driver at startup and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` for the WebKitGTK process, avoiding rendering glitches that show up specifically on that combo. Confirmed via blind A/B testing — only the proprietary driver is targeted; Nouveau / AMD / Intel are not touched.
- **Lyrics — cubic ease-out scroll animator** *(by [@kilyabin](https://github.com/kilyabin), PRs [#214](https://github.com/Psychotoxical/psysonic/pull/214) / [#215](https://github.com/Psychotoxical/psysonic/pull/215))*: The lyrics auto-scroll animation is replaced by a smoother cubic ease-out curve (renamed internally from `springScroll` to `easeScroll`). Active line transitions are noticeably less jerky on long line-spacing changes.
- **Fullscreen lyrics — fade bottom edge of plain lyrics scroll viewport** *(by [@kilyabin](https://github.com/kilyabin))*: Plain (unsynced) lyrics in the fullscreen player now fade out at the bottom of the scroll viewport via a `mask-image` gradient, matching the existing fade on the synced-lyrics overlay.
### Fixed
- **Mini player — main window minimises on open + width cap on non-tiling WMs** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Opening the mini now reliably minimises the main window (previously hit-or-miss on some WMs), and the mini's width is capped on non-tiling Linux WMs so it doesn't open larger than its intended footprint when the user's WM hands it the full screen.
- **Artist page — Top Songs continues playback past the last track** *(by [@kveld9](https://github.com/kveld9), PR [#220](https://github.com/Psychotoxical/psysonic/pull/220))*: Playing a song from the Artist page's Top Songs row no longer stops after the row's last track — the queue continues into the surrounding context as intended.
- **Padding fixes across several pages** *(by [@kveld9](https://github.com/kveld9), PR [#221](https://github.com/Psychotoxical/psysonic/pull/221))*: Layout polish, mostly aligning content to the page-level container padding instead of the inner card padding.
- **Jayfin theme — WCAG AA contrast fixes for nav + primary buttons** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Hover and active states on the Jayfin theme's sidebar nav items and primary buttons now pass WCAG AA contrast against the underlying background.
- **Lyrics — sidebar lyrics with YouLy+ source render as a single line** *(by [@kilyabin](https://github.com/kilyabin))*: Lines from the YouLyrics+ source were being split across multiple visual lines in the QueuePanel lyrics pane. Now collapse onto one line as intended.
- **Settings → Lyrics Sources — drag-and-drop survives mode toggle** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Reordering lyrics sources via drag-and-drop no longer resets when toggling the synced-vs-plain mode.
- **Folder browser — auto-contrast text on selected row** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Selected rows in the folder browser now compute text colour from the row's background luminance, so light themes don't paint white-on-white text.
- **Titlebar — theme-independent traffic-lights + song pill** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The macOS-style traffic-lights and the now-playing pill in the titlebar use fixed colours instead of theme tokens, so they stay legible on every theme without needing per-theme overrides.
### Reverted
- **Reverted: fs-player WebKitGTK CPU-cut patch** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: An earlier perf patch in the Fullscreen Player that disabled compositing under WebKitGTK turned out to cause animation regressions in real-world use. Reverted; the original code path is back.
### Changed
- **AudioMuse toggle — Alpha badge dropped** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The AudioMuse-AI integration has been stable for several releases; the "Alpha" tag in Settings → Server is removed.
## [1.42.1] - 2026-04-19
> **🚨 Critical bug fix for Windows users.** On 1.42.0, opening the mini player on Windows could stall Tauri's event loop: the mini would appear as a blank white window, neither the main window nor the mini could be closed, and the only way out was killing the process via Task Manager. **Please update immediately if you're on Windows 1.42.0.** macOS and Linux were not affected.
### Fixed
- **Mini player no longer hangs the app on Windows** *([@Psychotoxical](https://github.com/Psychotoxical))*: Creating the second WebView2 webview lazily from the `open_mini_player` invoke handler reliably froze the app on Windows — the mini opened blank, both windows became unresponsive, and the user had to kill the process from Task Manager. The builder + `main.minimize()` combo racing against WebView2's first paint was the trigger. The mini webview is now pre-built hidden in Tauri's `.setup()` on Windows, so the first open is a pure show/hide instead of creation + minimize. `open_mini_player` is simpler on all platforms, the minimize-main dance around show/hide is skipped on Windows, and Windows also goes back to the native window decorations (the earlier `decorations: false` mini titlebar was part of the hang surface).
- **Mini player syncs immediately on first open** *([@Psychotoxical](https://github.com/Psychotoxical))*: With the mini pre-created on Windows, the mount-time `mini:ready` event could race past the main window's bridge listener and leave the mini without a snapshot when the user actually opened it. The mini now also re-emits `mini:ready` on every window focus, so opening the mini always triggers a fresh sync regardless of startup ordering.
### Added
- **Optional “Preload mini player” setting on Linux + macOS** *([@Psychotoxical](https://github.com/Psychotoxical))*: Settings → General → App behaviour. Off by default. When enabled, the mini player window is built hidden at app start so the first open is instant instead of waiting a few seconds for WebKit to boot + React to hydrate + the bridge snapshot to arrive. Costs one extra WebKit process in the background permanently (~50100 MB RAM). Windows always preloads regardless of this toggle — it's how we work around the hang above, not an opt-in feature there.
## [1.42.0] - 2026-04-19
> **🛠️ Note on the 1.41.0 jump:** The 1.41.0 tag exists as an internal Draft release on GitHub — it was used to wire up and verify the Cachix substituter pipeline and never went public. **1.42.0 is the first public release after 1.40.0** and consolidates everything that was prepared for 1.41.0 plus the work landed on top in the days since.
>
> **❄️ Cachix is live for NixOS users.** The `psysonic.cachix.org` substituter is now actually fed by every release. Earlier 1.40.x runs were silently skipping the cache push (see *Fixed* below), so the first user to ask for a given output paid the full compile cost. Starting with 1.42.0, `nix run github:Psychotoxical/psysonic` and the NixOS module both pull the prebuilt closure straight from Cachix — no local Rust + symphonia + libopus build required.
### Added
- **Mini player — feature-complete second cut** *(Issue [#162](https://github.com/Psychotoxical/psysonic/issues/162), by [@Psychotoxical](https://github.com/Psychotoxical))*: The early-alpha mini from the internal 1.41.0 prep gets the rest of the workflow it was missing.
- **Expandable queue panel** with full track list, search-style overlay scrollbar (no width-eating gutter), drag-to-reorder using the existing PsyDnD system, and a localized right-click context menu (Play now / Remove from queue / Open album / Go to artist / Favorite / Song info — all forwarded to the main window via Tauri events so the source-of-truth playerStore stays consistent).
- **Custom in-page titlebar** on Windows + Linux with a drag region, the current track title and the queue / pin / open-main / close action icons. macOS keeps the native traffic-lights titlebar so the system look is preserved. The lower toolbar from the alpha is gone — its four buttons live in the titlebar now.
- **Persistent geometry**: window position, expanded-queue height and queue-open state all survive an app restart. Position is written to `<app_config_dir>/mini_player_pos.json` on every move (throttled), and re-applied after each show — Linux WMs (Mutter/KWin) re-centre hidden windows on show, so without re-applying the position would be lost on the second open.
- **User-bindable keyboard shortcut** in Settings → Shortcuts (`open-mini-player`, default unbound). The same chord toggles between main and mini regardless of which window has focus.
- **Layout polish**: cover shrinks 112 → 84 px, the right column gets title / artist / transport in a single block, progress + toolbar take full width.
- **Live theme / font / language sync**: changes in the main window propagate to an open mini via the shared localStorage `storage` event — no need to close + re-open the mini after rebinding a shortcut or switching themes.
- **Always-on-top reliability fix**: WMs that silently ignore `set_always_on_top(true)` when the flag is "already true" (KWin, certain Mutter releases) get a forced false → true cycle so the constraint is actually re-evaluated. The frontend also re-asserts the pin state on mount and on focus, so the user no longer has to click the pin button twice for it to stick.
- **Player bar — click-to-toggle duration / remaining time** *(contributed by [@kveld9](https://github.com/kveld9), PR [#212](https://github.com/Psychotoxical/psysonic/pull/212))*: Click the time read-out in the player bar to swap between total duration (`3:45`) and remaining time (`-2:34`). Updates live, persisted to `themeStore.showRemainingTime`. A small swap icon (⇄) and hover highlight signal the interaction.
- **Queue — ReplayGain in tech strip, expandable badge** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), originally by [@cucadmuh](https://github.com/cucadmuh) in PRs [#196](https://github.com/Psychotoxical/psysonic/pull/196) / [#201](https://github.com/Psychotoxical/psysonic/pull/201) — UX iteration by [@Psychotoxical](https://github.com/Psychotoxical) on cucadmuh's feedback)*: Tracks with ReplayGain metadata now show a small `RG ⌄` pill at the end of the codec/bitrate/sample-rate strip. Hover reveals the values via tooltip; click expands a second line ("ReplayGain · T -8.9 dB · A -11.0 dB · Peak 0.998") that is persisted across sessions. Hides itself for tracks without RG metadata.
- **Changelog — sidebar banner + dedicated `/whats-new` page** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The auto-popup modal that nagged the user on first launch after each update is replaced by a discreet sidebar banner. Clicking it opens a full `/whats-new` page that renders the latest CHANGELOG section in app — no separate Markdown viewer, no broken links to GitHub.
- **Favorites — genre column + Top Favorite Artists row** *(Issue [#87](https://github.com/Psychotoxical/psysonic/issues/87), by [@Psychotoxical](https://github.com/Psychotoxical))*: The Favorites tracklist now has a toggleable Genre column (alongside the existing Album column and multi-genre filter). A new horizontally scrolling "Top Favorite Artists" row sits between Radio Stations and Songs, aggregated from starred tracks and sorted by star count. Clicking an artist card narrows the song list to that artist.
- **Compilation filter on All Albums** *(Issue [#65](https://github.com/Psychotoxical/psysonic/issues/65), by [@Psychotoxical](https://github.com/Psychotoxical))*: A tri-state toggle in the Albums page header (All / Only compilations / Hide compilations) that reads the OpenSubsonic `isCompilation` tag exposed by Navidrome 0.61+. Client-side filter, no additional server calls. Translated into all 8 supported locales.
- **Sticky header on Albums, New Releases, Artists** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The header row with search/sort/genre/year controls now pins to the top while scrolling, so filters stay reachable without jumping back up. Works the same on all three browse pages.
- **Device Sync — album artist on both panels** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Album entries in both the library (left) and on-device (right) panels now display `Album · Artist` inline, so sampler discs and self-titled albums are no longer guesswork. Playlists unchanged.
- **NixOS — first-class flake install guide** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PRs [#209](https://github.com/Psychotoxical/psysonic/pull/209) / [#210](https://github.com/Psychotoxical/psysonic/pull/210))*: A new top-level `nixos-install.md` walks through adding Psysonic as a flake input, installing via `environment.systemPackages` / `home.packages`, and wiring up the public `psysonic.cachix.org` substituter so every NixOS user pulls prebuilt binaries. README links to it directly.
- **README — AppImage in the Linux install options + Cachix badge** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Linux install section now lists AppImage alongside `.deb`, `.rpm`, AUR and Nix flakes. A Cachix badge on the README header signals that NixOS users get prebuilt binaries.
### Changed
- **Genre filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The inline tagbox + dropdown (capped at 60 entries, ate header space when expanded) is replaced by a compact button that opens a portal-rendered popover with a search field and the full scrollable list of genres. Selected genres sort to the top. Used on Albums, New Releases, Random Albums and Favorites.
- **Year filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The From/To number inputs in the Albums header became a single button with a popover mirroring the genre filter pattern. When the filter is active, the button shows the range (e.g. `20202024`) in accent colour.
- **Sort picker — portal dropdown** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The two sort buttons on Albums (`AZ (Album)`, `AZ (Artist)`) collapse into one dropdown button showing the current choice. Generic `SortDropdown` component, reusable for other pages.
- **Device Sync — album/playlist meta inline** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `BrowserRow` renders secondary info inline with a `·` separator in muted colour instead of a separate right-aligned column, matching the on-device panel's format.
- **README — Arch/AUR fold-up** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Arch / AUR install instructions are folded into the Linux install section so the README stops scrolling forever.
### Fixed
- **Player bar — black-flash on WebKitGTK** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Linux users occasionally saw the entire player bar paint fully black for one frame when an unrelated layer elsewhere on the page invalidated. `contain: layout paint` makes the bar its own paint boundary so it can no longer be pulled into a surrounding dirty rect. No-op on platforms that don't exhibit the flash (Wayland-with-GPU, Chromium webviews on Windows / macOS).
- **Player bar — time-toggle tooltip uses the in-app TooltipPortal** *(follow-up to PR [#212](https://github.com/Psychotoxical/psysonic/pull/212), by [@Psychotoxical](https://github.com/Psychotoxical))*: The new time-swap control was rendering the native browser `title=` tooltip (unstyled OS popup, ignored by every other control). Switched to `data-tooltip="…"` so it matches every other player-bar tooltip.
- **Fullscreen player — lyrics menu toggle + readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Re-clicking the mic icon now actually closes the lyrics settings panel instead of the outside-click handler closing it and the click re-opening it — the trigger button is excluded from the outside-check. The panel itself is now a solid surface (no backdrop blur, near-opaque background, higher-contrast button text) so settings remain readable over the busy fullscreen background.
- **i18n — ArtistCardLocal album count** *(contributed by [@cucadmuh](https://github.com/cucadmuh))*: Local artist cards were rendering the album count with hardcoded German (`Album` / `Alben`). Switched to the existing plural-aware `artists.albumCount` key which already covers all 8 locales including Russian Slavic plurals.
- **Release CI — Cachix never receiving the psysonic closure** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `cachix-action` installs its post-build hook via `NIX_USER_CONF_FILES`, but the Determinate Nix daemon that runs the actual builds reads the system nix.conf — so the hook never fired. Only a couple of early prep paths ever reached the cache, never the compiled `psysonic` output. The release workflow now pushes the full closure explicitly after `nix build`; Cachix dedupes against paths already present, so redundancy is cheap.
### Contributors
- [@kveld9](https://github.com/kveld9) — click-to-toggle duration / remaining time in the player bar.
- [@cucadmuh](https://github.com/cucadmuh) — i18n fix for ArtistCardLocal, ReplayGain UX feedback that drove the expandable badge, NixOS install guide, README polish.
---
## [1.40.0] - 2026-04-18
### Added
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Install now** in the notification modal downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper re-approval, no manual DMG handling. The modal shows trust badges ("Notarized by Apple" + "Signature verified"), a 3-second restart countdown after install with a manual "Restart now" option, and hides redundant buttons during each download/install phase. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
### Changed
- **Device Sync — fixed naming scheme + playlist folders** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The user-configurable filename template is gone. Every sync now writes files under a single, non-negotiable scheme:
- Album / artist sources: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}`
- Playlist sources: `Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}` plus a self-contained `.m3u8` that references sibling filenames.
**Why:** different OSes normalised separators and special characters differently, so the same library synced from macOS and then plugged into a Windows machine appeared "different" and re-downloaded every album. The fixed scheme ends that forever.
**Playlist folders instead of the album tree:** playlists used to be scattered across the album structure as `.m3u8` references. For playlists with 40 artists that meant 40 new folders on the stick. Now every playlist is one self-contained folder; the `.m3u8` sits inside it and references siblings, so you can copy the whole folder anywhere.
**Migration for existing sticks:** a "Reorganize existing files…" button on the Device Sync page reads the legacy template from the v1 manifest, computes per-track rename pairs, detects collisions, and executes atomic `fs::rename`s. Empty directories left behind are cleaned up automatically. Playlist tracks synced under the old scheme are left for the next sync to re-download into the new playlist folder, rather than being force-moved.
**Album-Artist fallback:** libraries without an albumArtist tag fall back to the track artist — "Unknown Artist" is only ever a last-resort placeholder.
### Fixed
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
### Changed
### Contributors
- **Contributors list updated** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → About now credits PRs [#205](https://github.com/Psychotoxical/psysonic/pull/205) (Apple Music-style scrolling lyrics by [@kilyabin](https://github.com/kilyabin)), [#206](https://github.com/Psychotoxical/psysonic/pull/206) (Golos Text + Unbounded fonts with Cyrillic support by [@kilyabin](https://github.com/kilyabin)), and [#207](https://github.com/Psychotoxical/psysonic/pull/207) (WebKitGTK wheel scroll mode by [@cucadmuh](https://github.com/cucadmuh)).
- **PR [#205](https://github.com/Psychotoxical/psysonic/pull/205)** — Apple Music-style scrolling lyrics with spring-physics scroll, by [@kilyabin](https://github.com/kilyabin).
- **PR [#206](https://github.com/Psychotoxical/psysonic/pull/206)** — Golos Text + Unbounded fonts with Cyrillic support, by [@kilyabin](https://github.com/kilyabin).
- **PR [#207](https://github.com/Psychotoxical/psysonic/pull/207)** — WebKitGTK wheel scroll mode toggle, by [@cucadmuh](https://github.com/cucadmuh).
All three now credited in Settings → About.
---
+344
View File
@@ -0,0 +1,344 @@
# Psy Orbit
A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue.
No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it.
---
## Table of contents
- [For users](#for-users)
- [Starting a session (host)](#starting-a-session-host)
- [Joining (guest)](#joining-guest)
- [Suggesting tracks](#suggesting-tracks)
- [Approvals](#approvals)
- [Shared queue](#shared-queue)
- [Session settings](#session-settings)
- [Participants](#participants)
- [Ending the session](#ending-the-session)
- [Requirements & limits](#requirements--limits)
- [How it works (technical)](#how-it-works-technical)
- [Design goals](#design-goals)
- [Playlists as transport](#playlists-as-transport)
- [The host tick](#the-host-tick)
- [The guest tick](#the-guest-tick)
- [Data flow](#data-flow)
- [State shape](#state-shape)
- [Cleanup](#cleanup)
- [Security & privacy](#security--privacy)
- [Edge cases handled](#edge-cases-handled)
- [Code map](#code-map)
---
## For users
### Starting a session (host)
Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with:
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
- **Max guests** — cap on concurrent participants (132). You don't count.
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
### Joining (guest)
Two equivalent paths:
1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join.
2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join.
Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as.
### Suggesting tracks
Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search):
- **Double-click** a row → adds just that track.
- **Right-click → "Add to Orbit session"** — same effect, via context menu.
- **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone.
Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it.
### Approvals
By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides.
The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons:
- ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick.
- ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip.
Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed.
### Shared queue
Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that:
- **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range.
- **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions.
When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list.
### Session settings
Open via the gear icon in the session bar (host only):
- **Auto-approve suggestions** — on/off. Default off.
- **Automatic reshuffle** — on/off. Periodically FisherYates-shuffles the upcoming queue.
- **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off.
- **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer.
### Participants
Click the participant count in the session bar. Opens a popover:
- Host row at the top with a crown icon.
- Each connected guest with a user icon, username, and join timestamp.
- Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing.
Guests see the same list but read-only — no action buttons.
### Ending the session
- **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically.
- **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else.
- **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal.
- **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat).
### The help modal
Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand).
---
## Requirements & limits
- **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there.
- **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username.
- **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address.
- **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible.
- **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue.
---
## How it works (technical)
### Design goals
1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own.
2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session.
3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch.
4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election.
### Playlists as transport
Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`):
| Playlist | Who owns it | What's in it |
|---|---|---|
| `__psyorbit_<sid>` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. |
| `__psyorbit_<sid>_from_<user>__` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. |
All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active.
### The host tick
Fired every 2.5 s from `useOrbitHost`:
1. **Sweep all outboxes.** List every `__psyorbit_<sid>_from_<user>__` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment.
2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`.
3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's.
4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave.
5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, FisherYates-reorder the upcoming play queue + rewrite `state.lastShuffle`.
6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob.
7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`.
Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically.
### The guest tick
Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s:
1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState.
2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout.
3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal.
4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically).
5. **Auto-sync to host.** Three cases:
- Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own).
- Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick.
- First tick after join → mirror unconditionally (initial sync).
6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment.
### Data flow
```
Host (per tick) Navidrome Guest (per tick)
──────────────────────────────────────────────────────────────────────────────────
player.currentTrack
+ position
snapshotPlayerPatch ──► writeOrbitState ─┐
┌─session playlist─┐
│ comment = JSON │ ◄─readOrbitState
└──────────────────┘
parse OrbitState
syncToHost:
• getSong
• playTrack
• seek
• resume/pause
Guest suggests track Y
┌────────────────────────────────────────────────────────────┤
│ │
▼ │
suggestOrbitTrack
┌──guest outbox──┐
│ track list = Y │ ◄─updatePlaylist
└────────────────┘
Host: sweepGuestOutboxes ◄──────────┘
applyOutboxSnapshotsToState
(queue += Y, participants refreshed)
▼ (if auto-approve)
mergeNewSuggestionsIntoQueue
player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles
pending list
```
### State shape
All relevant types in `src/api/orbit.ts`:
```ts
interface OrbitState {
v: 3;
sid: string; // 8 hex chars
host: string; // navidrome username
name: string; // human-readable session name
started: number; // ms since epoch
maxUsers: number;
currentTrack: OrbitQueueItem | null;
isPlaying: boolean;
positionMs: number;
positionAt: number; // wall-clock ms of the last snapshot
queue: OrbitQueueItem[]; // suggestion history
playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming
playQueueTotal: number;
participants: OrbitParticipant[];
kicked: string[];
removed: { user: string; at: number }[];
lastShuffle: number;
settings: {
autoApprove: boolean;
autoShuffle: boolean;
shuffleIntervalMin: 1 | 5 | 10 | 15 | 30;
};
ended: boolean;
}
interface OrbitQueueItem {
trackId: string;
addedBy: string; // navidrome username
addedAt: number;
}
```
The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry.
### Cleanup
Three layers of defense against orphaned playlists:
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
### Security & privacy
- **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer.
- **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not.
- **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server.
- **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox.
- **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs.
---
## Edge cases handled
- **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock).
- **Host offline 15 s 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally.
- **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal.
- **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync.
- **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track".
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
- **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal.
---
## Code map
### State and types
- `src/api/orbit.ts``OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`.
- `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`.
### Lifecycle
- `src/utils/orbit.ts``startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
### Hooks
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
- `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection.
- `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists.
### UI — session bar and popovers
- `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons.
- `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle).
- `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button.
- `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions).
### UI — modals
- `src/components/OrbitStartModal.tsx` — session creation wizard.
- `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join.
- `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining.
- `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice.
- `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable).
- `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help).
### UI — queue views
- `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge).
- `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming).
- `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline.
### Supporting
- `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate.
- `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state.
- `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch.
+3
View File
@@ -20,6 +20,9 @@ All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm cred
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
### YouLyPlus (Lyrics)
If YouLyPlus mode is selected in Settings → Lyrics, Psysonic sends the track title, artist, album, duration, and ISRC (when available) to a community-operated [lyricsplus](https://github.com/ibratabian17/lyricsplus) backend to fetch word-synced karaoke lyrics. No account is required. Requests are routed through a list of public mirrors; the data they receive is limited to the search query above. This feature is disabled by default.
### NetEase Cloud Music (Lyrics)
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
+152 -120
View File
@@ -1,164 +1,196 @@
<div align="center">
<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://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
> [!WARNING]
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
---
# Psysonic
<div align="center">
<a href="https://discord.gg/AMnDRErm4u">
<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>
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
## The Ultimate Desktop Client for Self-Hosted Music Libraries
**Fast. Beautiful. Native. Feature-packed.**
Built primarily for **Navidrome**. Also compatible with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers with partial feature support.
<br>
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a> <a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
<br><br>
**No telemetry • Native performance • Massive feature set • Community driven**
</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.
![Psysonic Screenshot](public/screenshot1.png)
## ✨ Features
---
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
- ⚡ **Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (15 stars), and multi-select actions.
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
> [!WARNING]
> Psysonic is under heavy active development. Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
## 🗺️ Roadmap
## Server Compatibility
### 📋 Planned
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
**Psysonic is optimized first and foremost for Navidrome.**
Many advanced functions integrate directly with Navidrome APIs for the best possible experience. Other Subsonic-compatible servers generally work well, but some features may be limited depending on server capabilities.
## Why Psysonic?
Most Subsonic clients feel like web wrappers.
**Psysonic does not.**
It is a true desktop experience built with **Rust**, **Tauri v2**, and **React** for users who care about speed, aesthetics, customization, and serious music library management.
If you host your own music, this is what the premium experience should feel like.
---
## 📥 Installation
# Core Features
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
## Playback Engine
### 🐧 Linux
* Gapless playback
* Crossfade
* ReplayGain support
* Smart Loudness Normalization
* Infinite Queue
* Smart Radio sessions
* High responsiveness with low memory usage
## Audio Tools
* 10-band Equalizer
* Presets
* AutoEQ headphone correction
* Per-device optimization
## Library Power
* Lightning-fast search
* Albums / Artists / Tracks / Genres
* Ratings system
* Multi-select bulk actions
* Drag & drop playlist management
* Huge library friendly
## Lyrics & Discovery
* Synced lyrics with seek support
* Auto-scroll sidebar lyrics
* Fullscreen lyric mode
* Last.fm scrobbling
* Similar artists / love tracks / stats
## Personalization
* Huge theme collection
* Catppuccin / Nord inspired styles
* Glassmorphism effects
* Font customization
* Zoom controls
* Keybind remapping
* Theme Scheduler (day/night auto switch)
## Power User Extras
* CLI controls
* USB / portable sync
* Backup & restore settings
* In-app auto updater
* LAN / remote auto switching
---
# Orbit (Upcoming)
## Listen Together. In Sync. Soon.
Currently in final development and testing. Orbit will introduce synchronized shared listening sessions directly inside Psysonic.
* Host-controlled playback
* Join via link
* Shared listening sessions
* Guest song suggestions
* Real-time queue interaction
**Rolling out in an upcoming release. Community feedback will help shape the final experience.**
---
# Platforms
| OS | Support |
| ------- | --------------------------------------------------------------- |
| Windows | Native Installer *(certificate pending)* |
| macOS | Signed DMG |
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
Supports **8 languages** and growing.
---
# Install
## 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
## Windows
### 🍎 macOS
Download the latest installer from Releases.
- **macOS**: `.dmg` (Universal or Apple Silicon)
> SmartScreen warnings may appear until the code-signing certificate is active.
> [!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
> ```
## macOS
### 🪟 Windows
Download the signed DMG from Releases.
- **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
1. Download and install Psysonic.
2. Open the app and enter your Subsonic/Navidrome server details (URL, Username, Password).
3. If applicable, you can provide both an external URL and a local LAN IP. Psysonic allows you to quickly toggle between them in the Settings.
4. Enjoy your music!
## 🛠️ Development
If you want to build Psysonic from source or contribute to the project:
### Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [Rust](https://www.rust-lang.org/) (v1.75+)
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
- macOS: `brew install cmake`
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
### Setup
# Development
```bash
# Clone the repository
git clone https://github.com/Psychotoxical/psysonic.git
cd psysonic
# Install node dependencies
npm install
# Run in development mode
npm run tauri:dev
```
# Build for production
Build release:
```bash
npm run tauri:build
```
## 🤝 Contributing
---
Contributions are completely welcome! Whether it is translating the app into a new language, fixing a bug, or proposing a new feature.
# Privacy First
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
* No telemetry
* No spyware nonsense
* No analytics harvesting
* Your library stays yours
## 📄 License
---
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
# Community
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
Join Discord, report bugs, suggest features, share themes, shape the future.
## 🔒 Privacy
---
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
# License
GNU GPL v3.0
---
<div align="center">
## Stop using boring music clients.
## Use Psysonic.
</div>
+237
View File
@@ -0,0 +1,237 @@
# Release Process (Strict SOP)
This document defines the **only allowed** release workflow for this repository.
All maintainers should follow it exactly.
## 1) Branch roles
- `main`:
- primary development branch
- all regular feature/fix work lands here via PR
- should usually carry a development version (for example `X.Y.Z-dev`)
- `next`:
- release-candidate (RC) stabilization branch
- receives promoted changes from `main`
- receives RC-only fixes during freeze
- `release`:
- stable release branch
- only receives promoted commits from `next`
Direct push to these branches is not part of normal human workflow. Use PRs and promotion workflows.
## 2) Versioning rules (mandatory)
Version is authoritative in `package.json` and `package-lock.json`.
- `main` version format: `X.Y.Z-dev`
- `next` version format: `X.Y.Z-rc.N`
- `release` version format: `X.Y.Z`
Rules:
1. Never edit versions manually in random commits.
2. Version transitions must happen through the defined promotion workflows.
3. Tags must match package version:
- RC: `app-vX.Y.Z-rc.N`
- Stable: `app-vX.Y.Z`
## 3) Standard release flow
### Step A: Prepare in `main`
1. Merge ready PRs into `main`.
2. Confirm CI is green on `main`.
### Step B: Promote to RC (`next`)
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
- resets `next` to `main` snapshot
- auto-bump package version in `next` to next `-rc.N`
- commit and push version bump
3. Push on `next` triggers **Next Channel** workflow:
- build/publish RC artifacts for all platforms
- run Nix verification path
### Step C: Stabilize RC
1. Test RC artifacts.
2. If fixes are needed, follow Section 5 (RC fix policy).
3. Repeat Step B as needed until release candidate is accepted.
### Step D: Promote to stable (`release`)
1. Run workflow: **Promote next to release**.
2. Workflow behavior:
- resets `release` to `next` snapshot
- finalize version from `-rc.N` to `X.Y.Z`
- commit and push finalized version
3. Push on `release` triggers **Release Channel** workflow:
- stable artifact publish
- Nix verification
- opens PR to bump `main` to next minor `-dev`
### Step E: Move `main` forward
1. Merge the auto-generated PR that bumps `main` to next minor dev version.
2. Confirm `main` now uses `X.(Y+1).0-dev`.
3. Update AUR package metadata for the same stable version:
- bump `pkgver` in `packages/aur/PKGBUILD`
- regenerate `packages/aur/.SRCINFO`
- publish/update in AUR remote
## 4) Freeze policy (RC stabilization window)
When RC freeze starts:
- Do **not** run `Promote main to next` automatically or casually.
- Only approved release manager(s) may run promotion workflows.
- `next` accepts only stabilization changes (fixes/docs/chore required for release quality).
- New features remain in `main` and wait for next cycle.
Freeze ends after `next -> release` promotion is complete.
## 5) RC fix policy (strict backport/forward-port rules)
If a bug is discovered during RC stabilization:
1. Create dedicated fix branch from `next`:
- example: `fix/rc-crash-login`
2. Open PR: `fix/rc-crash-login -> next`
3. After merge to `next`, create dedicated backport branch from `main`:
- example: `fix/backport-rc-crash-login-main`
4. Cherry-pick (or re-apply) same fix.
5. Open PR: `fix/backport-rc-crash-login-main -> main`
6. Merge this `main` backport PR before the next `Promote main to next` run.
This is mandatory. RC-only fixes may not stay only in `next`.
Alternative allowed order:
- implement first in `main`, then promote `main -> next`.
But if `main` is ahead with non-release features and promotion is frozen, use the `next-first + mandatory main backport` flow above.
## 6) Post-release critical hotfix policy (default path)
After a stable release `X.Y.Z`, critical fixes must be shipped as a patch release:
- next stable target is always `X.Y.(Z+1)`
- RC tags for hotfix cycle: `app-vX.Y.(Z+1)-rc.N`
- final stable tag: `app-vX.Y.(Z+1)`
Never re-use or overwrite `X.Y.Z` tags/releases.
### Case A: `next` is not yet used for the next minor
This case is uncommon in this repository but allowed.
1. Create hotfix branch from `release`.
2. Implement fix and open PR to `release`.
3. Move patch line through `next` RC flow (`X.Y.(Z+1)-rc.N`).
4. Promote `next -> release` for final `X.Y.(Z+1)`.
5. Backport fix to `main` via dedicated PR (mandatory).
### Case B (default): `next` already tracks next minor
This is the expected real-world case.
Assume:
- `release` is `1.9.0`
- `main`/`next` already moved to `1.10.0-*`
- critical bug requires `1.9.1`
Required steps:
1. Announce **hotfix override window** and freeze normal next-minor RC flow.
2. Create hotfix branch from `release` (`1.9.0` baseline).
3. Implement fix and merge into `release` branch via PR.
4. Temporarily align `next` to the hotfix patch line for RC publication.
5. Publish hotfix RC(s): `1.9.1-rc.N`.
6. Promote `next -> release` to finalize `1.9.1`.
7. Backport/cherry-pick same fix into `main` via dedicated PR (mandatory).
8. Restore `next` back to the normal next-minor line from `main`.
9. Announce end of hotfix override and resume normal RC cycle.
Hard rule: no feature work may be merged into `next` during hotfix override.
## 7) Idempotency and rerun behavior
Manual workflow reruns should be safe:
- rerunning **Promote main to next**:
- no change if no new commits
- version bump occurs only when needed for next RC number
- rerunning **Promote next to release**:
- no change if release already matches next
- no extra version increment beyond `X.Y.Z`
- rerunning release publish:
- main dev bump step should no-op when `main` already has target dev version
Rerun is allowed for recovery, but must be announced in release channel/chat.
## 8) Hard rules and prohibitions
Do:
- use PRs for all code changes
- keep channel promotions deterministic and force-push only through approved promotion workflows
- require green CI before promotions
- document exceptions in PR description
Do not:
- manually retag or overwrite release tags
- manually edit `package.json` version outside defined release flow
- merge feature PRs into `next` during freeze
- skip the `next/release -> main` backport for RC fixes or hotfixes
- force-push `next` or `release` manually outside promotion workflows
## 9) Incident handling
If an incorrect promotion happened:
1. Stop further promotions immediately.
2. Announce incident and current branch SHAs.
3. Create corrective PRs (do not use destructive git history rewrites on protected branches).
4. Re-run affected workflows only after corrective PRs are merged.
## 10) Operator checklist (quick)
Before `main -> next`:
- [ ] `main` CI green
- [ ] freeze status known
- [ ] release manager approval
- [ ] branch rules allow workflow `--force-with-lease` on `next`
Before `next -> release`:
- [ ] RC validation complete
- [ ] all RC fixes merged to `next`
- [ ] corresponding backports to `main` completed or queued with owners
- [ ] branch rules allow workflow `--force-with-lease` on `release`
After stable release:
- [ ] verify stable artifacts exist
- [ ] merge auto PR for next `-dev` bump in `main`
- [ ] publish AUR update (`PKGBUILD` + `.SRCINFO`)
- [ ] announce cycle close
For post-release hotfix:
- [ ] patch target decided: `X.Y.(Z+1)`
- [ ] hotfix override for `next` announced
- [ ] fix merged to release patch line
- [ ] hotfix backport PR to `main` merged
- [ ] `next` restored to normal next-minor line
Nix note:
- `nix-npm-deps-hash-sync.yml` runs on pushes to `main`, `next`, and `release`.
- `verify-nix` in channel publish still performs full lock/hash refresh verification for release artifacts.
- Channel-local nix refresh PRs are advisory and can be overwritten by later reset-based promotions.
- If a nix refresh must survive release cycles, ensure the same change is merged into `main`.
+15 -2
View File
@@ -60,7 +60,7 @@ _psysonic_server_ids() {
_psysonic_globals() {
compadd -J options -X 'option' -- \
--help --version --info --json --quiet --player completions
--help --version --info --json --quiet --logs --player completions
}
integer i pidx=0
@@ -68,12 +68,25 @@ for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --player ]] && pidx=i
done
if [[ ${words[CURRENT-1]} == --tail ]]; then
_message -e descriptions 'number of lines'
return
fi
if (( pidx == 0 )); then
local has_logs=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --logs ]] && has_logs=1
done
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
compadd help bash zsh
return
fi
_psysonic_globals
if (( has_logs )); then
compadd -- --tail -f --follow
else
_psysonic_globals
fi
return
fi
+17 -1
View File
@@ -54,6 +54,14 @@ _psysonic_snapshot_json() {
_psysonic_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
local prev=""
(( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ $prev == --tail ]]; then
_psysonic_compopt -o default
COMPREPLY=()
return
fi
local i pidx=0
for (( i = 1; i < COMP_CWORD; i++ )); do
@@ -61,11 +69,19 @@ _psysonic_complete() {
done
if (( pidx == 0 )); then
local has_logs=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1
done
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
return
fi
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
if (( has_logs )); then
_psysonic_compreply_from_compgen '--tail -f --follow' "$cur"
else
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur"
fi
return
fi
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776169885,
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
"lastModified": 1776877367,
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
"npmDepsHash": "sha256-PYqvngGz/yreDiQxAEIhD5nuEPezAELj/zaEpJ7c1f4="
}
+134
View File
@@ -0,0 +1,134 @@
# Installing Psysonic on NixOS (flake)
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
## Prerequisites
**Flakes** enabled (e.g. in `configuration.nix`):
```nix
nix.settings.experimental-features = [ "nix-command" "flakes" ];
```
## Binary cache (Cachix)
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
- **Substituter URL:** `https://psysonic.cachix.org`
- **Public key** (trust this only if it matches what you expect from the cache owners):
```text
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
```
### NixOS (`configuration.nix` or a flake module)
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
```nix
{
nix.settings = {
substituters = [
"https://psysonic.cachix.org"
"https://cache.nixos.org/"
];
trusted-public-keys = [
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
];
};
}
```
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
## Install on NixOS (flake configuration)
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
### Example: top-level `flake.nix` + `nixosConfigurations`
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
psysonic.url = "github:Psychotoxical/psysonic";
};
outputs = { self, nixpkgs, ... }@inputs: let
system = "x86_64-linux";
in {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./configuration.nix
{
environment.systemPackages = [
inputs.psysonic.packages.${system}.psysonic
];
}
];
};
};
}
```
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
```nix
environment.systemPackages = with pkgs; [
# …
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
];
```
### Pinning a revision or tag
Follow **`main`** (above) to track the moving branch, or pin for reproducibility:
```nix
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.34.13"; # example: release tag
```
Use a tag or commit SHA that exists on GitHub; the release workflow keeps **`flake.lock`** and **`nix/upstream-sources.json`** (`npmDepsHash`) in sync on tagged releases.
### Apply configuration
- **NixOS flake host**
```bash
sudo nixos-rebuild switch --flake .#my-host
```
- **Home Manager** (if used separately)
```bash
home-manager switch --flake .#my-user@my-host
```
## Home Manager
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
```nix
home.packages = [
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
];
```
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
## Desktop entry
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
## Troubleshooting (Linux / WebKit)
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
## More detail in-repo
- **`flake.nix`** — package outputs, `devShell`, supported systems (see comments there for `nix build` / `nix develop`).
- **`nix/psysonic.nix`** — how the app is built from this source tree.
- **`.github/workflows/release.yml`** — `verify-nix` job: refreshes lock/npm hash and pushes store paths to Cachix on release tags.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.34.13",
"version": "1.44.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.34.13",
"version": "1.44.0-dev",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
@@ -2671,9 +2671,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.16",
"version": "1.44.0-dev",
"private": true,
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.12
pkgver=1.43.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -20,7 +20,7 @@ makedepends=(
'nasm'
'cmake'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/**
* Align src-tauri/Cargo.toml and src-tauri/tauri.conf.json with package.json "version".
* Used after npm version in promote workflows so bundle names match release semver.
*/
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const version = require(path.join(root, 'package.json')).version;
if (!version || typeof version !== 'string') {
console.error('package.json version missing');
process.exit(1);
}
const cargoPath = path.join(root, 'src-tauri', 'Cargo.toml');
let cargo = fs.readFileSync(cargoPath, 'utf8');
if (!/^version = "[^"]*"$/m.test(cargo)) {
console.error('Cargo.toml: expected a package-level line: version = "..."');
process.exit(1);
}
cargo = cargo.replace(/^version = "[^"]*"$/m, `version = "${version}"`);
fs.writeFileSync(cargoPath, cargo);
console.log(`Cargo.toml -> ${version}`);
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.version = version;
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
console.log(`tauri.conf.json -> ${version}`);
+94 -13
View File
@@ -944,6 +944,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dasp_frame"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
dependencies = [
"dasp_sample",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -1169,6 +1178,18 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ebur128"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9"
dependencies = [
"bitflags 1.3.2",
"dasp_frame",
"dasp_sample",
"smallvec",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1302,6 +1323,18 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -1908,6 +1941,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2548,6 +2590,17 @@ dependencies = [
"redox_syscall 0.7.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.3.8"
@@ -3115,9 +3168,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.3"
version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
dependencies = [
"dunce",
"is-wsl",
@@ -3653,10 +3706,11 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.34.16"
version = "1.44.0-rc1"
dependencies = [
"biquad",
"discord-rich-presence",
"ebur128",
"futures-util",
"id3",
"libc",
@@ -3665,6 +3719,7 @@ dependencies = [
"reqwest 0.12.28",
"ringbuf",
"rodio",
"rusqlite",
"serde",
"serde_json",
"souvlaki",
@@ -4116,6 +4171,20 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4236,9 +4305,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.12"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5971,9 +6040,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uds_windows"
@@ -6115,6 +6184,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -6186,11 +6261,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
@@ -6199,7 +6274,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.51.0",
]
[[package]]
@@ -6339,9 +6414,9 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
@@ -7139,6 +7214,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.16"
version = "1.44.0-dev"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -49,6 +49,8 @@ lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7"
rusqlite = { version = "0.37", features = ["bundled"] }
ebur128 = "0.1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+2 -1
View File
@@ -3,7 +3,7 @@
"identifier": "default",
"description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"windows": ["main", "mini"],
"permissions": [
"core:default",
"shell:default",
@@ -36,6 +36,7 @@
"core:window:allow-is-fullscreen",
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:window:allow-set-size",
"core:webview:allow-create-webview-window",
"process:allow-restart",
"updater:default"
@@ -1063,10 +1063,11 @@ fn set_hw_params_from_format(
hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
}
BufferSize::Default => {
// These values together represent a moderate latency and wakeup interval.
// Without them, we are at the mercy of the device
hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?;
hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?;
// Larger buffer than upstream cpal default: after Symphonia seeks or CPU
// spikes (analysis, demux) the mixer needs more margin before ALSA underruns.
// ~40 ms period, ~200 ms total buffer (device may round to nearest supported).
hw_params.set_period_time_near(40_000, alsa::ValueOr::Nearest)?;
hw_params.set_buffer_time_near(200_000, alsa::ValueOr::Nearest)?;
}
}
@@ -33,7 +33,6 @@ use std::fmt;
use std::mem;
use std::os::raw::c_char;
use std::ptr::null;
use std::rc::Rc;
use std::slice;
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::sync::{Arc, Mutex};
@@ -342,7 +342,7 @@ impl Device {
/// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it.
fn ensure_future_audio_client(
&self,
) -> Result<MutexGuard<Option<IAudioClientWrapper>>, windows::core::Error> {
) -> Result<MutexGuard<'_, Option<IAudioClientWrapper>>, windows::core::Error> {
let mut lock = self.future_audio_client.lock().unwrap();
if lock.is_some() {
return Ok(lock);
+892
View File
@@ -0,0 +1,892 @@
use std::path::PathBuf;
use std::io::Cursor;
use std::sync::Mutex;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use ebur128::{EbuR128, Mode as Ebur128Mode};
use rusqlite::{params, Connection, OptionalExtension};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use tauri::Manager;
pub const WAVEFORM_ALGO_VERSION: i64 = 4;
pub const LOUDNESS_ALGO_VERSION: i64 = 1;
/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin).
pub fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool {
if bin_count <= 0 {
return false;
}
let n = bin_count as usize;
bins.len() == n.saturating_mul(2)
}
#[derive(Debug, Clone)]
pub struct TrackKey {
pub track_id: String,
pub md5_16kb: String,
}
#[derive(Debug, Clone)]
pub struct WaveformEntry {
pub bins: Vec<u8>,
pub bin_count: i64,
pub is_partial: bool,
pub known_until_sec: f64,
pub duration_sec: f64,
pub updated_at: i64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LoudnessEntry {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LoudnessSnapshot {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
pub struct AnalysisCache {
conn: Mutex<Connection>,
}
/// Ranged HTTP seeding uses `stream:<subsonicId>` (see `playback_identity`); backfill
/// and IPC often use the bare `<subsonicId>`. Rows may exist under either key.
fn track_id_cache_variants(id: &str) -> Vec<String> {
let mut out = vec![id.to_string()];
if let Some(bare) = id.strip_prefix("stream:") {
if !bare.is_empty() {
out.push(bare.to_string());
}
} else {
out.push(format!("stream:{id}"));
}
out
}
impl AnalysisCache {
pub fn init(app: &tauri::AppHandle) -> Result<Self, String> {
let db_path = analysis_db_path(app)?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_connection(&conn).map_err(|e| e.to_string())?;
migrate_schema(&conn).map_err(|e| e.to_string())?;
Ok(Self { conn: Mutex::new(conn) })
}
/// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant).
pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result<u64, String> {
if track_id.trim().is_empty() {
return Ok(0);
}
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let mut total: u64 = 0;
for tid in track_id_cache_variants(track_id) {
let n = conn
.execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid])
.map_err(|e| e.to_string())?;
total = total.saturating_add(n as u64);
}
Ok(total)
}
/// Remove all cached waveform rows across all tracks/variants.
pub fn delete_all_waveforms(&self) -> Result<u64, String> {
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let n = conn
.execute("DELETE FROM waveform_cache", [])
.map_err(|e| e.to_string())?;
Ok(n as u64)
}
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
let now = now_unix_ts();
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO analysis_track (
track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
status = excluded.status,
waveform_algo_version = excluded.waveform_algo_version,
loudness_algo_version = excluded.loudness_algo_version,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
status,
WAVEFORM_ALGO_VERSION,
LOUDNESS_ALGO_VERSION,
now
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO waveform_cache (
track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
bins = excluded.bins,
bin_count = excluded.bin_count,
is_partial = excluded.is_partial,
known_until_sec = excluded.known_until_sec,
duration_sec = excluded.duration_sec,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
entry.bins,
entry.bin_count,
if entry.is_partial { 1 } else { 0 },
entry.known_until_sec,
entry.duration_sec,
entry.updated_at
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO loudness_cache (
track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET
integrated_lufs = excluded.integrated_lufs,
true_peak = excluded.true_peak,
recommended_gain_db = excluded.recommended_gain_db,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
entry.integrated_lufs,
entry.true_peak,
entry.recommended_gain_db,
entry.target_lufs,
entry.updated_at
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let row = conn
.query_row(
r#"
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
FROM waveform_cache w
JOIN analysis_track a
ON a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.track_id = ?1
AND w.md5_16kb = ?2
AND a.waveform_algo_version = ?3
"#,
params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION],
|row| {
Ok(WaveformEntry {
bins: row.get(0)?,
bin_count: row.get(1)?,
is_partial: row.get::<_, i64>(2)? != 0,
known_until_sec: row.get(3)?,
duration_sec: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
}
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let exists: i64 = conn
.query_row(
r#"
SELECT EXISTS (
SELECT 1
FROM loudness_cache l
JOIN analysis_track a
ON a.track_id = l.track_id
AND a.md5_16kb = l.md5_16kb
WHERE l.track_id = ?1
AND l.md5_16kb = ?2
AND a.loudness_algo_version = ?3
)
"#,
params![key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(exists != 0)
}
pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
FROM waveform_cache w
JOIN analysis_track a
ON a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.track_id = ?1
AND a.waveform_algo_version = ?2
ORDER BY w.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row = conn
.query_row(
SQL,
params![tid, WAVEFORM_ALGO_VERSION],
|row| {
Ok(WaveformEntry {
bins: row.get(0)?,
bin_count: row.get(1)?,
is_partial: row.get::<_, i64>(2)? != 0,
known_until_sec: row.get(3)?,
duration_sec: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
if let Some(e) = row {
if waveform_cache_blob_len_ok(&e.bins, e.bin_count) {
return Ok(Some(e));
}
}
}
Ok(None)
}
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at
FROM loudness_cache l
JOIN analysis_track a
ON a.track_id = l.track_id
AND a.md5_16kb = l.md5_16kb
WHERE l.track_id = ?1
AND a.loudness_algo_version = ?2
ORDER BY l.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row = conn
.query_row(
SQL,
params![tid, LOUDNESS_ALGO_VERSION],
|row| {
Ok(LoudnessSnapshot {
integrated_lufs: row.get(0)?,
true_peak: row.get(1)?,
recommended_gain_db: row.get(2)?,
target_lufs: row.get(3)?,
updated_at: row.get(4)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
if row.is_some() {
return Ok(row);
}
}
Ok(None)
}
}
pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_lufs: f64) -> f64 {
let mut recommended_gain_db = target_lufs - integrated_lufs;
if true_peak > 0.0 {
let true_peak_dbtp = 20.0 * true_peak.log10();
let max_gain_db = -1.0 - true_peak_dbtp;
if recommended_gain_db > max_gain_db {
recommended_gain_db = max_gain_db;
}
}
recommended_gain_db.clamp(-24.0, 24.0)
}
/// Result of [`seed_from_bytes_execute`] / CPU seed queue: callers use it to avoid redundant UI events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeedFromBytesOutcome {
/// Wrote waveform (and loudness when PCM decode succeeded).
Upserted,
/// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version.
SkippedWaveformCacheHit,
/// `AnalysisCache` was not registered on the app handle.
SkippedNoAnalysisCache,
}
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
pub fn seed_from_bytes_execute(
app: &tauri::AppHandle,
track_id: &str,
bytes: &[u8],
) -> Result<SeedFromBytesOutcome, String> {
let started = Instant::now();
let Some(cache) = app.try_state::<AnalysisCache>() else {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
track_id,
bytes.len()
);
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
};
let key = TrackKey {
track_id: track_id.to_string(),
md5_16kb: md5_first_16kb(bytes),
};
if let Some(existing) = cache.get_waveform(&key)? {
if !existing.bins.is_empty() {
if cache.loudness_row_exists_for_key(&key)? {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
);
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
}
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
}
}
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
crate::app_deprintln!(
"[analysis] full-track analysis start track_id={} input_mib={:.2} md5_16kb={}",
track_id,
mib,
key.md5_16kb
);
crate::app_deprintln!(
"[analysis] full-track analysis work: Symphonia decodes the entire buffer twice (frame timeline, then PCM peak bins), then EBU R128 integrated loudness + true-peak when that succeeds — CPU-bound; large lossless files often take minutes"
);
let build = (|| -> Result<(bool, usize), String> {
cache.touch_track_status(&key, "queued")?;
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
(
bins,
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)),
true,
)
}
None => (derive_waveform_bins(bytes, 500), None, false),
};
let bins_len = wf_bins.len();
let waveform = WaveformEntry {
bins: wf_bins,
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: now_unix_ts(),
};
cache.upsert_waveform(&key, &waveform)?;
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt {
let loudness = LoudnessEntry {
integrated_lufs,
true_peak,
recommended_gain_db,
target_lufs,
updated_at: now_unix_ts(),
};
cache.upsert_loudness(&key, &loudness)?;
}
cache.touch_track_status(&key, "ready")?;
Ok((used_pcm_decode, bins_len))
})();
let elapsed_ms = started.elapsed().as_millis();
match &build {
Ok((used_pcm_decode, bins_len)) => {
crate::app_deprintln!(
"[analysis] full-track analysis done track_id={} elapsed_ms={} decode_path={} bins_len={} ebu_loudness_cached={}",
track_id,
elapsed_ms,
if *used_pcm_decode {
"pcm_ebur128"
} else {
"byte_envelope_no_ebu"
},
bins_len,
*used_pcm_decode
);
}
Err(e) => {
crate::app_deprintln!(
"[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}",
track_id,
elapsed_ms,
e
);
}
}
match build {
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
Err(e) => Err(e),
}
}
fn now_unix_ts() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn md5_first_16kb(bytes: &[u8]) -> String {
let n = bytes.len().min(16 * 1024);
format!("{:x}", md5::compute(&bytes[..n]))
}
fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
if bin_count == 0 || bytes.is_empty() {
return Vec::new();
}
let mut peak_half = vec![0u8; bin_count];
for (i, slot) in peak_half.iter_mut().enumerate() {
let start = i * bytes.len() / bin_count;
let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len());
let mut peak: u8 = 0;
for &b in &bytes[start..end] {
let centered = if b >= 128 { b - 128 } else { 128 - b };
if centered > peak {
peak = centered;
}
}
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
}
let mut out = peak_half.clone();
out.extend_from_slice(&peak_half);
out
}
struct PcmScanResult {
bins: Vec<u8>,
loudness: Option<(f64, f64, f64, f64)>,
}
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
fn analyze_loudness_and_waveform(
bytes: &[u8],
target_lufs: f64,
bin_count: usize,
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
if bytes.is_empty() || bin_count == 0 {
return None;
}
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
if decoded_frames == 0 {
return None;
}
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
let (i, t, r, tgt) = scanned.loudness?;
Some((i, t, r, tgt, scanned.bins))
}
/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is
/// `codec_params.n_frames` when the container reports total track length — used
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
/// when the buffer grows.
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
let mut format = probed.format;
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
let codec_params = track.codec_params.clone();
let mut decoder = symphonia::default::get_codecs()
.make(&codec_params, &DecoderOptions::default())
.ok()?;
let mut total: u64 = 0;
let mut loop_i: u32 = 0;
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(_) => break,
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let n = samples.samples().len();
if n < n_ch || n % n_ch != 0 {
continue;
}
total += (n / n_ch) as u64;
loop_i = loop_i.wrapping_add(1);
if loop_i % 128 == 0 {
std::thread::yield_now();
}
}
if total == 0 {
None
} else {
Some((total, timeline_hint))
}
}
fn normalize_peak_bins(bin_max: &[f32]) -> Vec<u8> {
let bin_count = bin_max.len();
if bin_count == 0 {
return Vec::new();
}
let mut sorted: Vec<f32> = bin_max.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))];
let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))];
let range = (p99 - p5).max(1e-8);
let mut out = vec![0u8; bin_count];
for i in 0..bin_count {
let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0);
let shaped = t.powf(0.52);
out[i] = (8.0 + shaped * 247.0).min(255.0) as u8;
}
out
}
fn decode_scan_pcm(
bytes: &[u8],
bin_count: usize,
decoded_frames: u64,
timeline_hint: Option<u64>,
loudness_target_lufs: Option<f64>,
) -> Option<PcmScanResult> {
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
let mut format = probed.format;
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let codec_params = track.codec_params.clone();
let mut decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
return None;
}
};
let mut bin_max = vec![0.0f32; bin_count];
let mut bin_sum = vec![0.0f32; bin_count];
let mut bin_n = vec![0u32; bin_count];
let mut ebu: Option<EbuR128> = None;
let mut ebu_channels: u32 = 0;
let mut sample_peak_abs = 0.0_f64;
let mut fed_any_frames = false;
let mut sample_idx: u64 = 0;
let mut loop_i: u32 = 0;
// Fixed timeline from metadata when available; otherwise fall back to decoded
// length (full-buffer analysis only — partial byte windows still shift, but
// then we usually lack n_frames anyway).
let bin_grid_frames = timeline_hint
.map(|n| n.max(decoded_frames))
.unwrap_or(decoded_frames)
.max(1);
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(_) => break,
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
if loudness_target_lufs.is_some() && ebu.is_none() {
let ch = spec.channels.count() as u32;
let sr = spec.rate;
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
ebu_channels = ch;
}
Err(e) => {
crate::app_deprintln!(
"[analysis] EbuR128 init failed: channels={} sample_rate={} err={}",
ch,
sr,
e
);
return None;
}
}
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
if slice.len() < n_ch || slice.len() % n_ch != 0 {
continue;
}
let frames = slice.len() / n_ch;
for f in 0..frames {
let base = f * n_ch;
let mut acc = 0.0f32;
for c in 0..n_ch {
acc += slice[base + c];
}
let mono = acc / (n_ch as f32);
let mag = mono.abs();
if mag.is_finite() {
let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize;
let bin = bin.min(bin_count.saturating_sub(1));
bin_max[bin] = bin_max[bin].max(mag);
bin_sum[bin] += mag;
bin_n[bin] = bin_n[bin].saturating_add(1);
}
for c in 0..n_ch {
let v = (slice[base + c] as f64).abs();
if v.is_finite() && v > sample_peak_abs {
sample_peak_abs = v;
}
}
sample_idx += 1;
}
if loudness_target_lufs.is_some() {
if let Some(e) = ebu.as_mut() {
match e.add_frames_f32(samples.samples()) {
Ok(_) => fed_any_frames = true,
Err(err) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
return None;
}
}
}
}
loop_i = loop_i.wrapping_add(1);
if loop_i % 128 == 0 {
std::thread::yield_now();
}
}
let mut bin_mean = vec![0.0f32; bin_count];
for i in 0..bin_count {
if bin_n[i] > 0 {
bin_mean[i] = bin_sum[i] / (bin_n[i] as f32);
}
}
let peak_u8 = normalize_peak_bins(&bin_max);
let mean_u8 = normalize_peak_bins(&bin_mean);
let mut bins = Vec::with_capacity(peak_u8.len().saturating_mul(2));
bins.extend_from_slice(&peak_u8);
bins.extend_from_slice(&mean_u8);
let loudness = if let Some(target_lufs) = loudness_target_lufs {
if !fed_any_frames {
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
return None;
}
let Some(ebu) = ebu else {
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
return None;
};
let integrated_lufs = match ebu.loudness_global() {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] loudness_global failed: {}", e);
return None;
}
};
if !integrated_lufs.is_finite() {
crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite");
return None;
}
let mut true_peak = 0.0_f64;
let mut true_peak_ok = true;
for ch in 0..ebu_channels {
match ebu.true_peak(ch) {
Ok(v) if v.is_finite() && v > true_peak => true_peak = v,
Ok(_) => {}
Err(e) => {
true_peak_ok = false;
crate::app_deprintln!("[analysis] true_peak unavailable: {}", e);
break;
}
}
}
if !true_peak_ok {
true_peak = sample_peak_abs;
}
let recommended_gain_db =
recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
} else {
None
};
Some(PcmScanResult { bins, loudness })
}
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
Ok(base.join("audio-analysis.sqlite"))
}
fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(())
}
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS analysis_track (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS waveform_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS loudness_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb, target_lufs)
);
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
"#,
)?;
Ok(())
}
+1672 -152
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -8,6 +8,10 @@ use std::path::PathBuf;
#[cfg(target_os = "linux")]
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use std::{
collections::VecDeque,
io::{BufRead, BufReader, Read, Seek, SeekFrom},
};
use serde_json::Value;
use tauri::{AppHandle, Emitter, Runtime};
@@ -88,6 +92,41 @@ pub fn wants_info_json(args: &[String]) -> bool {
wants_info(args) && args.iter().skip(1).any(|a| a == "--json")
}
pub fn wants_tail(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "--tail")
}
pub fn wants_logs(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "--logs")
}
pub fn wants_follow(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "-f" || a == "--follow")
}
pub fn logs_tail_lines(args: &[String]) -> Result<Option<usize>, String> {
let mut i = 1usize;
while i < args.len() {
if args[i] == "--tail" {
let Some(raw) = args.get(i + 1) else {
return Err("--tail requires a numeric value".to_string());
};
if raw.starts_with('-') {
return Err("--tail requires a positive integer value".to_string());
}
let n: usize = raw
.parse()
.map_err(|_| "--tail requires a positive integer value".to_string())?;
if n == 0 {
return Err("--tail must be greater than 0".to_string());
}
return Ok(Some(n));
}
i += 1;
}
Ok(None)
}
pub fn wants_quiet(args: &[String]) -> bool {
args.iter()
.skip(1)
@@ -229,6 +268,10 @@ pub fn print_help(program: &str) {
eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus.");
eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not");
eprintln!(" published a snapshot yet.\n");
eprintln!("── Logs channel (normal + debug) ──");
eprintln!(" {program} --logs Print recent log lines and exit.");
eprintln!(" {program} --logs --tail <lines> Print the last <lines> entries.");
eprintln!(" {program} --logs --tail <lines> -f Keep streaming new lines.\n");
eprintln!("── Remote commands (--player …) ──");
eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS.");
eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window.");
@@ -267,6 +310,88 @@ pub fn print_help(program: &str) {
eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status.");
}
const CLI_TAIL_DEFAULT_LINES: usize = 200;
fn print_log_tail_once(path: &std::path::Path, lines: usize) -> Result<u64, String> {
let file = std::fs::OpenOptions::new()
.read(true)
.open(path)
.map_err(|e| format!("open {}: {e}", path.display()))?;
let mut ring: VecDeque<String> = VecDeque::with_capacity(lines.max(1));
let mut reader = BufReader::new(file);
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
if ring.len() >= lines.max(1) {
ring.pop_front();
}
ring.push_back(line.trim_end_matches('\n').to_string());
}
for row in ring {
println!("{row}");
}
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
Ok(len)
}
fn follow_log_file(path: &std::path::Path, mut offset: u64) -> Result<(), String> {
loop {
let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if len < offset {
offset = 0;
}
if len > offset {
let mut f = std::fs::OpenOptions::new()
.read(true)
.open(path)
.map_err(|e| format!("open {}: {e}", path.display()))?;
f.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?;
let mut chunk = String::new();
f.read_to_string(&mut chunk).map_err(|e| e.to_string())?;
if !chunk.is_empty() {
print!("{chunk}");
}
offset = len;
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Print from the shared normal/debug channel and exit.
pub fn run_tail_and_exit(args: &[String]) -> ! {
let tail_lines = match logs_tail_lines(args) {
Ok(Some(n)) => n,
Ok(None) => CLI_TAIL_DEFAULT_LINES,
Err(e) => {
eprintln!("NOT OK: {e}");
std::process::exit(2);
}
};
let path = crate::logging::cli_log_channel_path();
if !path.exists() {
eprintln!("NOT OK: no log channel file yet at {}", path.display());
std::process::exit(3);
}
let offset = match print_log_tail_once(&path, tail_lines) {
Ok(o) => o,
Err(e) => {
eprintln!("NOT OK: {e}");
std::process::exit(1);
}
};
if wants_follow(args) {
if let Err(e) = follow_log_file(&path, offset) {
eprintln!("NOT OK: {e}");
std::process::exit(1);
}
}
std::process::exit(0);
}
/// Wait for the webview to write `psysonic-cli-library.json` after `cli:library-list`.
fn read_library_cli_response_blocking(max_wait: Duration) -> String {
let path = cli_library_response_path();
+38 -5
View File
@@ -210,9 +210,26 @@ fn cache_and_return(
}
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
///
/// In debug builds (i.e. `npx tauri dev`) every step of the IPC handshake is
/// logged so the renderer's terminal output shows exactly where the
/// connection breaks. Release builds stay completely silent.
fn try_connect() -> Option<DiscordIpcClient> {
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
client.connect().ok()?;
let mut client = match DiscordIpcClient::new(DISCORD_APP_ID) {
Ok(c) => c,
Err(_e) => {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] new() failed (app_id={}): {}", DISCORD_APP_ID, _e);
return None;
}
};
if let Err(_e) = client.connect() {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
return None;
}
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] IPC connected (app_id={})", DISCORD_APP_ID);
Some(client)
}
@@ -316,7 +333,9 @@ pub async fn discord_update_presence(
// When paused: clear activity completely to avoid any timer issues
// When playing: show full activity with timer
if !is_playing {
if client.clear_activity().is_err() {
if let Err(_e) = client.clear_activity() {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] clear_activity (pause) failed, dropping client: {}", _e);
*guard = None;
}
return Ok(());
@@ -339,8 +358,17 @@ pub async fn discord_update_presence(
Timestamps::new()
});
if client.set_activity(activity).is_err() {
if let Err(_e) = client.set_activity(activity) {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] set_activity failed, dropping client: {}", _e);
*guard = None;
} else {
#[cfg(debug_assertions)]
crate::app_eprintln!(
"[discord] activity sent: \"{}\" / \"{}\"",
details_text,
state_text
);
}
Ok(())
@@ -351,8 +379,13 @@ pub async fn discord_update_presence(
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() {
if let Err(_e) = client.clear_activity() {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] clear_activity failed, dropping client: {}", _e);
*guard = None;
} else {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] activity cleared");
}
}
Ok(())
+2106 -113
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
#[cfg(unix)]
use libc;
use std::collections::VecDeque;
use std::io::Write;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum LoggingMode {
Off = 0,
Normal = 1,
Debug = 2,
}
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
const LOG_BUFFER_MAX_LINES: usize = 20_000;
fn log_buffer() -> &'static Mutex<VecDeque<String>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
}
/// Shared runtime file used by CLI `--tail` to read normal/debug log channel.
pub fn cli_log_channel_path() -> std::path::PathBuf {
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
if !dir.is_empty() {
return std::path::PathBuf::from(dir).join("psysonic-cli.log");
}
}
std::env::temp_dir().join("psysonic-cli.log")
}
fn parse_logging_mode(mode: &str) -> Option<LoggingMode> {
match mode.trim().to_ascii_lowercase().as_str() {
"off" => Some(LoggingMode::Off),
"normal" => Some(LoggingMode::Normal),
"debug" => Some(LoggingMode::Debug),
_ => None,
}
}
pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> {
let parsed = parse_logging_mode(mode)
.ok_or_else(|| "invalid logging mode (expected: off | normal | debug)".to_string())?;
LOGGING_MODE.store(parsed as u8, Ordering::Release);
Ok(())
}
fn current_mode() -> LoggingMode {
match LOGGING_MODE.load(Ordering::Acquire) {
0 => LoggingMode::Off,
2 => LoggingMode::Debug,
_ => LoggingMode::Normal,
}
}
pub fn should_log_normal() -> bool {
!matches!(current_mode(), LoggingMode::Off)
}
pub fn should_log_debug() -> bool {
matches!(current_mode(), LoggingMode::Debug)
}
pub fn append_log_line(line: String) {
let mut buf = log_buffer().lock().unwrap();
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
}
buf.push_back(line.clone());
drop(buf);
let path = cli_log_channel_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{}", line);
}
}
pub fn export_logs_to_file(path: &str) -> Result<usize, String> {
let snapshot = {
let buf = log_buffer().lock().unwrap();
if buf.is_empty() {
String::new()
} else {
let mut s = buf.iter().cloned().collect::<Vec<_>>().join("\n");
s.push('\n');
s
}
};
std::fs::write(path, snapshot).map_err(|e| e.to_string())?;
let lines = {
let buf = log_buffer().lock().unwrap();
buf.len()
};
Ok(lines)
}
pub(crate) fn log_timestamp_local() -> String {
let now = ::std::time::SystemTime::now()
.duration_since(::std::time::UNIX_EPOCH)
.unwrap_or_default();
let millis = now.subsec_millis();
#[cfg(unix)]
{
use std::ffi::CStr;
let secs: libc::time_t = now.as_secs() as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let mut date_buf: [libc::c_char; 64] = [0; 64];
let mut tz_buf: [libc::c_char; 16] = [0; 16];
let date_fmt = b"%Y-%m-%d %H:%M:%S\0";
let tz_fmt = b"%z\0";
unsafe {
if libc::localtime_r(&secs as *const libc::time_t, &mut tm as *mut libc::tm).is_null() {
return format!("{}.{:03}", now.as_secs(), millis);
}
let date_ok = libc::strftime(
date_buf.as_mut_ptr(),
date_buf.len(),
date_fmt.as_ptr().cast(),
&tm as *const libc::tm,
);
if date_ok == 0 {
return format!("{}.{:03}", now.as_secs(), millis);
}
let tz_ok = libc::strftime(
tz_buf.as_mut_ptr(),
tz_buf.len(),
tz_fmt.as_ptr().cast(),
&tm as *const libc::tm,
);
let date = CStr::from_ptr(date_buf.as_ptr()).to_string_lossy();
if tz_ok == 0 {
return format!("{}.{:03}", date, millis);
}
let tz = CStr::from_ptr(tz_buf.as_ptr()).to_string_lossy();
return format!("{}.{:03} {}", date, millis, tz);
}
}
#[cfg(not(unix))]
{
format!("{}.{:03}", now.as_secs(), millis)
}
}
#[macro_export]
macro_rules! app_eprintln {
() => {{
if $crate::logging::should_log_normal() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}]", ts);
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
($($arg:tt)*) => {{
if $crate::logging::should_log_normal() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}] {}", ts, format_args!($($arg)*));
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
}
#[macro_export]
macro_rules! app_deprintln {
() => {{
if $crate::logging::should_log_debug() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}]", ts);
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
($($arg:tt)*) => {{
if $crate::logging::should_log_debug() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}] {}", ts, format_args!($($arg)*));
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
}
+62 -1
View File
@@ -1,17 +1,71 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq)]
enum GpuVendor {
Nvidia,
Intel,
Amd,
}
#[cfg(target_os = "linux")]
fn detect_gpu_vendor() -> Option<GpuVendor> {
use std::fs;
if fs::metadata("/proc/driver/nvidia/version").is_ok() {
return Some(GpuVendor::Nvidia);
}
// Iterate every `/sys/class/drm/card*` — hybrid laptops expose multiple
// cards, and some systems have no `card0` at all.
let entries = fs::read_dir("/sys/class/drm").ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with("card") || name.contains('-') {
continue;
}
let Ok(vendor_id) = fs::read_to_string(entry.path().join("device/vendor")) else {
continue;
};
match vendor_id.trim() {
"0x10de" => return Some(GpuVendor::Nvidia),
"0x8086" => return Some(GpuVendor::Intel),
"0x1002" => return Some(GpuVendor::Amd),
_ => {}
}
}
None
}
fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
// Users can still override by setting these vars before launch.
//
// Safety: set_var modifies global process state. These calls are safe here
// because we're in main() before the Tauri runtime starts — no other threads
// exist yet. If this code moves to lazy init or a plugin context, it would
// need synchronization or marking as unsafe (Rust 2024+).
#[cfg(target_os = "linux")]
unsafe {
{
if std::env::var("GDK_BACKEND").is_err() {
std::env::set_var("GDK_BACKEND", "x11");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
// NVIDIA proprietary adds a small but reproducible overhead on the
// DMA-BUF renderer path (blind A/B confirmed on NVIDIA + proprietary).
// Unknown GPUs keep the WebKitGTK default — VMs, ARM SBCs and anything
// exotic should not be regressed by a guess.
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
&& matches!(detect_gpu_vendor(), Some(GpuVendor::Nvidia))
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
let args: Vec<String> = std::env::args().collect();
@@ -31,6 +85,13 @@ fn main() {
if psysonic_lib::cli::wants_info(&args) {
psysonic_lib::cli::run_info_and_exit(&args);
}
if psysonic_lib::cli::wants_logs(&args) {
psysonic_lib::cli::run_tail_and_exit(&args);
}
if psysonic_lib::cli::wants_tail(&args) {
eprintln!("NOT OK: --tail is only valid with --logs");
std::process::exit(2);
}
psysonic_lib::run();
}
+5 -6
View File
@@ -203,11 +203,11 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
&TaskbarList, None, CLSCTX_INPROC_SERVER,
) {
Ok(t) => t,
Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
Err(e) => { crate::app_eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
};
if let Err(e) = taskbar.HrInit() {
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
crate::app_eprintln!("[psysonic] taskbar: HrInit failed: {e}");
return;
}
@@ -224,7 +224,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
let mut buttons = make_buttons(h_prev, h_play, h_next);
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
return;
}
@@ -234,7 +234,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
crate::app_eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
drop(Box::from_raw(data));
}
}
@@ -268,8 +268,7 @@ pub fn update_taskbar_icon(is_playing: bool) {
let mut btns = [btn];
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
#[cfg(debug_assertions)]
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
crate::app_deprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
let _ = e;
}
}
+3 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.16",
"version": "1.44.0-dev",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -23,8 +23,7 @@
"decorations": true,
"transparent": false,
"visible": true,
"dragDropEnabled": false,
"devtools": false
"dragDropEnabled": false
}
],
"security": {
@@ -33,7 +32,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUWFjOQo=",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUVBhYzkK",
"endpoints": [
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
],
+295 -84
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } 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';
@@ -11,9 +11,11 @@ import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
// Eager — main browsing flow, loaded on first paint
import Home from './pages/Home';
import Albums from './pages/Albums';
import Artists from './pages/Artists';
@@ -22,39 +24,57 @@ import NewReleases from './pages/NewReleases';
import Favorites from './pages/Favorites';
import RandomMix from './pages/RandomMix';
import RandomLanding from './pages/RandomLanding';
import Settings from './pages/Settings';
import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
import Statistics from './pages/Statistics';
import MostPlayed from './pages/MostPlayed';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import LuckyMixPage from './pages/LuckyMix';
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 FolderBrowser from './pages/FolderBrowser';
import DeviceSync from './pages/DeviceSync';
import NowPlayingPage from './pages/NowPlaying';
// Lazy — visited rarely or on-demand. Each becomes its own chunk so the
// initial bundle stays smaller and these pages don't block first paint.
const Settings = lazy(() => import('./pages/Settings'));
const Statistics = lazy(() => import('./pages/Statistics'));
const Help = lazy(() => import('./pages/Help'));
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
const Tracks = lazy(() => import('./pages/Tracks'));
import MiniPlayer from './components/MiniPlayer';
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal';
import DownloadFolderModal from './components/DownloadFolderModal';
import GlobalConfirmModal from './components/GlobalConfirmModal';
import OrbitAccountPicker from './components/OrbitAccountPicker';
import OrbitHelpModal from './components/OrbitHelpModal';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import OverlayScrollArea from './components/OverlayScrollArea';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
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 TitleBar from './components/TitleBar';
import { IS_LINUX } from './utils/platform';
import OrbitSessionBar from './components/OrbitSessionBar';
import OrbitStartTrigger from './components/OrbitStartTrigger';
import { useOrbitHost } from './hooks/useOrbitHost';
import { useOrbitGuest } from './hooks/useOrbitGuest';
import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit';
import { useOrbitStore } from './store/orbitStore';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
@@ -73,7 +93,13 @@ import { initHotCachePrefetch } from './hotCachePrefetch';
import i18n from './i18n';
import { playByOpaqueId } from './utils/playByOpaqueId';
import { switchActiveServer } from './utils/switchActiveServer';
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
import {
usePlayerStore,
initAudioListeners,
songToTrack,
shuffleArray,
flushPlayQueuePosition,
} from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
@@ -82,6 +108,7 @@ import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './sto
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
import { useZipDownloadStore } from './store/zipDownloadStore';
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
import PasteClipboardHandler from './components/PasteClipboardHandler';
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
let cliPremuteVolume: number | null = null;
@@ -92,12 +119,47 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
/**
* Avoid grabbing the queue resizer when aiming at the main overlay scrollbar.
* Uses the real main viewport edge (not innerWidth queueWidth sidebar/zoom skew that).
* Only the main-route thumb counts (not queue/mini/sidebar thumbs selector is scoped).
*
* The queue resizer is 6px and sits on the main|queue seam with ~3px overlapping the main
* column (layout.css `.resizer-queue`). Treating `clientX <= mainRight` as "main" suppressed
* that overlap and felt like a dead resize strip at certain widths. Thumb hit slop must not
* extend past `mainRight` or it steals grabs on the resizer.
*/
function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, queueWidth: number): boolean {
const vp = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null;
const mainRight = vp ? vp.getBoundingClientRect().right : window.innerWidth - queueWidth;
/** Pixels of the resizer that lie left of the main column's right edge (see `.resizer-queue`). */
const RESIZER_BLEED_INTO_MAIN = 4;
if (clientX <= mainRight - RESIZER_BLEED_INTO_MAIN) return true;
const thumbs = document.querySelectorAll<HTMLElement>('.app-shell-route-scroll .overlay-scroll__thumb');
const xSlop = 22;
const vPad = 40;
for (let i = 0; i < thumbs.length; i++) {
const r = thumbs[i].getBoundingClientRect();
if (r.height < 4 || r.width < 1) continue;
if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue;
const thumbHitRight = Math.min(r.right + xSlop, mainRight);
if (clientX >= r.left - 6 && clientX <= thumbHitRight) return true;
}
return false;
}
function AppShell() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
const [isTilingWm, setIsTilingWm] = useState(false);
// Orbit session hooks: idle until the local store marks a role.
useOrbitHost();
useOrbitGuest();
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
@@ -110,6 +172,11 @@ function AppShell() {
}).catch(() => {});
}, []);
useEffect(() => {
const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown';
document.documentElement.setAttribute('data-platform', platform);
}, []);
useEffect(() => {
const win = getCurrentWindow();
// Check initial state (e.g. app launched maximised / already fullscreen).
@@ -140,9 +207,22 @@ function AppShell() {
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
const loggingMode = useAuthStore(s => s.loggingMode);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
// Mini player → main: route requests dispatched as `psy:navigate`
// CustomEvents from the bridge land here so React Router can take over.
useEffect(() => {
const onPsyNavigate = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.to) navigate(detail.to);
};
window.addEventListener('psy:navigate', onPsyNavigate);
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
}, [navigate]);
// Sync custom titlebar preference with native decorations on Linux
// On tiling WMs decorations are always off (no native title bar to replace).
@@ -157,6 +237,10 @@ function AppShell() {
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
}, [linuxWebkitKineticScroll]);
useEffect(() => {
invoke('set_logging_mode', { mode: loggingMode }).catch(() => {});
}, [loggingMode]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
const serverAtStart = activeServerId;
@@ -181,9 +265,17 @@ function AppShell() {
};
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
// Reset scroll position on route change
// Orbit orphan sweep — delete our own leftover session / outbox playlists
// from crashed or force-closed sessions so they don't clutter the ND
// playlist view. Runs once per login; safe and best-effort.
useEffect(() => {
document.querySelector('.content-body')?.scrollTo({ top: 0 });
if (!isLoggedIn || !activeServerId) return;
void cleanupOrphanedOrbitPlaylists();
}, [isLoggedIn, activeServerId]);
// Reset scroll position on route change (main viewport is overlay scroll)
useEffect(() => {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname]);
// Auto-navigate to offline library when no connection but cached content exists
@@ -227,14 +319,9 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
useEffect(() => {
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
setChangelogModalOpen(true);
}
}, []);
// Post-update changelog is now surfaced via a dismissible banner in the
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
// modal takeover on startup.
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
@@ -276,13 +363,11 @@ 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.
// ── Global DnD fix for Linux/WebKitGTK / Wayland ─────────────────
// dragover/dragenter: WebKitGTK needs preventDefault so external drops are not
// a permanent "forbidden" cursor. dragstart (capture): cancel native drags from
// the page (e.g. SVG grips); Wayland can otherwise leave a stuck GTK drag-proxy.
// In-app moves use psy-drag (mouse events). Harmless on Windows/macOS.
useEffect(() => {
const allow = (e: DragEvent) => {
e.preventDefault();
@@ -310,9 +395,14 @@ function AppShell() {
e.preventDefault();
};
const blockDragStart = (e: DragEvent) => {
e.preventDefault();
};
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
document.addEventListener('dragstart', blockDragStart, true);
document.addEventListener('keydown', blockSelectAll, true);
document.addEventListener('selectstart', blockSelectStart);
@@ -320,16 +410,29 @@ function AppShell() {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
document.removeEventListener('dragstart', blockDragStart, true);
document.removeEventListener('keydown', blockSelectAll, true);
document.removeEventListener('selectstart', blockSelectStart);
};
}, []);
// Pause CSS animations when the browser tab is hidden (`document.hidden`).
// Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from
// Rust (see components.css). WebView2 can keep compositing without the former.
useEffect(() => {
const update = () => {
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
};
document.addEventListener('visibilitychange', update);
update();
return () => document.removeEventListener('visibilitychange', update);
}, []);
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
className="app-shell"
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
@@ -355,6 +458,7 @@ function AppShell() {
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<NowPlayingDropdown />
<OrbitStartTrigger />
<button
className="queue-toggle-btn"
onClick={toggleQueue}
@@ -364,38 +468,52 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
)}
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/most-played" element={<MostPlayed />} />
<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 />} />
<Route path="/folders" element={<FolderBrowser />} />
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
<div className="content-body app-shell-route-host">
<OverlayScrollArea
className="app-shell-route-scroll"
viewportClassName="app-shell-route-scroll__viewport"
viewportId={APP_MAIN_SCROLL_VIEWPORT_ID}
measureDeps={[location.pathname, isQueueVisible, queueWidth, floatingPlayerBar]}
railInset="panel"
>
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/tracks" element={<Tracks />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/most-played" element={<MostPlayed />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/whats-new" element={<WhatsNew />} />
<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 />} />
<Route path="/folders" element={<FolderBrowser />} />
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
</Suspense>
</OverlayScrollArea>
</div>
</div>
</main>
@@ -404,6 +522,8 @@ function AppShell() {
className="resizer resizer-queue"
onMouseDown={(e) => {
e.preventDefault();
if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) return;
if (shouldSuppressQueueResizerMouseDown(e.clientX, e.clientY, queueWidth)) return;
setIsDraggingQueue(true);
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
@@ -418,9 +538,11 @@ function AppShell() {
<ContextMenu />
<SongInfoModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<OrbitAccountPicker />
<OrbitHelpModal />
<TooltipPortal />
<AppUpdater />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div>
);
}
@@ -742,8 +864,10 @@ function TauriEventBridge() {
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const el = e.target as HTMLElement;
const tag = el?.tagName;
const editable = Boolean(el?.isContentEditable);
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
const chord = buildInAppBinding(e);
if (chord) {
@@ -790,6 +914,9 @@ function TauriEventBridge() {
win.isFullscreen().then(fs => win.setFullscreen(!fs));
break;
}
case 'open-mini-player':
invoke('open_mini_player').catch(() => {});
break;
}
};
window.addEventListener('keydown', onKey);
@@ -851,17 +978,47 @@ function TauriEventBridge() {
unlisten.push(u);
}
// window:close-requested is emitted by Rust (prevent_close + emit).
// JS decides: minimize to tray or exit, based on user setting.
// Shared exit path: flush play-queue position so other devices can
// resume from where we left off, tear down any active Orbit session,
// then ask Rust to exit. Each step is capped at 1500 ms so a slow
// server can't keep the app hanging on quit; the playback heartbeat
// is the safety net for anything that didn't make it out in time.
const performExit = async () => {
await Promise.race([
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
]);
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app');
};
// window:close-requested is emitted by Rust (prevent_close + emit) on
// the X-button. JS decides: minimize to tray or exit.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide();
} else {
await invoke('exit_app');
await performExit();
}
});
if (cancelled) { u(); return; }
unlisten.push(u);
// app:force-quit bypasses the minimize-to-tray decision — used by the
// tray "Exit" menu item and the macOS red close button.
const fq = await listen('app:force-quit', async () => {
await performExit();
});
if (cancelled) { fq(); return; }
unlisten.push(fq);
};
setup();
@@ -930,6 +1087,12 @@ export default function App() {
const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
// Mini Player window: detected via Tauri window label. Rendered without
// router / sidebar / full audio listeners — it just listens for state + sends
// control events. Label is read synchronously from a global set in main.tsx
// so the initial render picks the right tree.
const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini';
useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
@@ -938,6 +1101,51 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
// Main window only: push playback state to mini window + handle control events.
useEffect(() => {
if (isMiniWindow) return;
return initMiniPlayerBridgeOnMain();
}, [isMiniWindow]);
// Main window only: optionally pre-create the mini player webview hidden so
// the first open is instant. Windows already does this unconditionally in
// Rust .setup() as a hang workaround — skip here to avoid double-building.
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
useEffect(() => {
if (isMiniWindow || IS_WINDOWS || !preloadMiniPlayer) return;
invoke('preload_mini_player').catch(() => {});
}, [isMiniWindow, preloadMiniPlayer]);
// Mini window only: re-hydrate persisted appearance stores when the main
// window writes new values. Both webviews share localStorage (same origin),
// so the `storage` event fires here whenever main mutates a key — but
// Zustand persist only reads localStorage on initial load, hence the
// explicit rehydrate.
useEffect(() => {
if (!isMiniWindow) return;
const onStorage = (e: StorageEvent) => {
if (!e.key) return;
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
else if (e.key === 'psysonic_language' && e.newValue) {
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [isMiniWindow]);
if (isMiniWindow) {
return (
<DragDropProvider>
<MiniPlayer />
<GlobalConfirmModal />
<TooltipPortal />
</DragDropProvider>
);
}
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
// because they live in separate grid cells. Document-level zoom is not used
@@ -1003,23 +1211,26 @@ export default function App() {
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
<WindowVisibilityProvider>
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
</WindowVisibilityProvider>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { invoke } from '@tauri-apps/api/core';
/** Snake_case payload from the Rust `fetch_bandsintown_events` command. */
interface RawBandsintownEvent {
datetime: string;
venue_name: string;
venue_city: string;
venue_region: string;
venue_country: string;
url: string;
on_sale_datetime: string;
lineup: string[];
}
export interface BandsintownEvent {
datetime: string;
venueName: string;
venueCity: string;
venueRegion: string;
venueCountry: string;
url: string;
onSaleDatetime: string;
lineup: string[];
}
const cache = new Map<string, BandsintownEvent[]>();
const inflight = new Map<string, Promise<BandsintownEvent[]>>();
function cacheKey(name: string): string {
return name.trim().toLowerCase();
}
/**
* Fetch upcoming events for an artist. Results are cached in RAM for the session
* (no TTL restart drops them). Concurrent calls for the same artist share one
* inflight promise. Failures resolve to an empty array never throws.
*/
export async function fetchBandsintownEvents(artistName: string): Promise<BandsintownEvent[]> {
const key = cacheKey(artistName);
if (!key) return [];
const hit = cache.get(key);
if (hit) return hit;
const pending = inflight.get(key);
if (pending) return pending;
const promise = (async () => {
try {
const raw = await invoke<RawBandsintownEvent[]>('fetch_bandsintown_events', {
artistName,
});
const events: BandsintownEvent[] = (raw ?? []).map(r => ({
datetime: r.datetime,
venueName: r.venue_name,
venueCity: r.venue_city,
venueRegion: r.venue_region,
venueCountry: r.venue_country,
url: r.url,
onSaleDatetime: r.on_sale_datetime,
lineup: r.lineup ?? [],
}));
cache.set(key, events);
return events;
} catch {
cache.set(key, []);
return [];
} finally {
inflight.delete(key);
}
})();
inflight.set(key, promise);
return promise;
}
+76
View File
@@ -300,3 +300,79 @@ export async function lastfmScrobble(
// best effort
}
}
export interface LastfmTrackInfo {
listeners: number;
playcount: number;
userPlaycount: number | null;
userLoved: boolean;
tags: string[];
url: string | null;
}
export async function lastfmGetTrackInfo(
artist: string,
track: string,
username?: string,
): Promise<LastfmTrackInfo | null> {
try {
const params: Record<string, string> = { method: 'track.getInfo', artist, track };
if (username) params.username = username;
const data = await call(params, false, true);
const t = data?.track;
if (!t) return null;
const rawTags = t.toptags?.tag;
const tags = rawTags
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
: [];
const userPc = t.userplaycount != null ? Number(t.userplaycount) : null;
return {
listeners: Number(t.listeners) || 0,
playcount: Number(t.playcount) || 0,
userPlaycount: Number.isFinite(userPc) ? userPc : null,
userLoved: t.userloved === '1' || t.userloved === 1,
tags,
url: t.url ?? null,
};
} catch {
return null;
}
}
export interface LastfmArtistStats {
listeners: number;
playcount: number;
userPlaycount: number | null;
tags: string[];
url: string | null;
bio: string | null;
}
export async function lastfmGetArtistStats(
artist: string,
username?: string,
): Promise<LastfmArtistStats | null> {
try {
const params: Record<string, string> = { method: 'artist.getInfo', artist };
if (username) params.username = username;
const data = await call(params, false, true);
const a = data?.artist;
if (!a) return null;
const rawTags = a.tags?.tag;
const tags = rawTags
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
: [];
const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null;
const bioRaw = (a.bio?.content || a.bio?.summary || '').trim();
return {
listeners: Number(a.stats?.listeners) || 0,
playcount: Number(a.stats?.playcount) || 0,
userPlaycount: Number.isFinite(userPc) ? userPc : null,
tags,
url: a.url ?? null,
bio: bioRaw || null,
};
} catch {
return null;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { invoke } from '@tauri-apps/api/core';
export interface NdLibrary {
id: number;
name: string;
}
export interface NdUser {
id: string;
userName: string;
name: string;
email: string;
isAdmin: boolean;
libraryIds: number[];
lastLoginAt?: string | null;
lastAccessAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface NdLoginResult {
token: string;
userId: string;
isAdmin: boolean;
}
export async function ndLogin(
serverUrl: string,
username: string,
password: string,
): Promise<NdLoginResult> {
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
}
function extractLibraryIds(o: Record<string, unknown>): number[] {
const libs = o.libraries;
if (!Array.isArray(libs)) return [];
const ids: number[] = [];
for (const l of libs) {
const id = (l as Record<string, unknown>)?.id;
if (typeof id === 'number') ids.push(id);
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
}
return ids;
}
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
if (!Array.isArray(raw)) return [];
return raw.map(u => {
const o = u as Record<string, unknown>;
return {
id: String(o.id ?? ''),
userName: String(o.userName ?? ''),
name: String(o.name ?? ''),
email: String(o.email ?? ''),
isAdmin: !!o.isAdmin,
libraryIds: extractLibraryIds(o),
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
createdAt: o.createdAt as string | undefined,
updatedAt: o.updatedAt as string | undefined,
};
});
}
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
if (!Array.isArray(raw)) return [];
return raw.map(l => {
const o = l as Record<string, unknown>;
const id = typeof o.id === 'number'
? o.id
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
return { id, name: String(o.name ?? '') };
}).filter(l => l.id > 0);
}
export async function ndSetUserLibraries(
serverUrl: string,
token: string,
id: string,
libraryIds: number[],
): Promise<void> {
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
}
export async function ndCreateUser(
serverUrl: string,
token: string,
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
): Promise<{ id: string }> {
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
const o = (raw as Record<string, unknown> | null) ?? {};
return { id: String(o.id ?? '') };
}
export async function ndUpdateUser(
serverUrl: string,
token: string,
id: string,
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
): Promise<void> {
await invoke('nd_update_user', { serverUrl, token, id, ...data });
}
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
await invoke('nd_delete_user', { serverUrl, token, id });
}
+97
View File
@@ -0,0 +1,97 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin';
import type { SubsonicSong } from './subsonic';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
async function getToken(force = false): Promise<string> {
const { getActiveServer, getBaseUrl } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
if (!server || !baseUrl) throw new Error('No active server configured');
if (!force && cachedToken?.serverUrl === baseUrl) return cachedToken.token;
const result = await ndLogin(baseUrl, server.username, server.password);
cachedToken = { serverUrl: baseUrl, token: result.token };
return result.token;
}
function asString(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
}
function asNumber(v: unknown): number | undefined {
return typeof v === 'number' && isFinite(v) ? v : undefined;
}
function mapNdSong(o: Record<string, unknown>): SubsonicSong {
// Navidrome's REST shape differs from Subsonic — flatten into the SubsonicSong contract.
const id = asString(o.id ?? o.mediaFileId);
const albumId = asString(o.albumId);
return {
id,
title: asString(o.title),
artist: asString(o.artist),
album: asString(o.album),
albumId,
artistId: asString(o.artistId) || undefined,
duration: asNumber(o.duration) !== undefined ? Math.round(asNumber(o.duration)!) : 0,
track: asNumber(o.trackNumber),
discNumber: asNumber(o.discNumber),
// Navidrome usually exposes coverArtId; many builds also accept the song id directly.
coverArt: asString(o.coverArtId) || albumId || id || undefined,
year: asNumber(o.year),
userRating: asNumber(o.rating),
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
genre: typeof o.genre === 'string' ? o.genre : undefined,
bitRate: asNumber(o.bitRate),
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
size: asNumber(o.size),
samplingRate: asNumber(o.sampleRate),
bitDepth: asNumber(o.bitDepth),
};
}
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count';
/**
* Fetch a sorted, paginated slice of all songs via Navidrome's native REST API.
* Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome.
*/
export async function ndListSongs(
start: number,
end: number,
sort: NdSongSort = 'title',
order: 'ASC' | 'DESC' = 'ASC',
): Promise<SubsonicSong[]> {
const baseUrl = useAuthStore.getState().getBaseUrl();
if (!baseUrl) throw new Error('No server configured');
const callOnce = async (token: string): Promise<unknown> =>
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
let token = await getToken();
let raw: unknown;
try {
raw = await callOnce(token);
} catch (err) {
const msg = String(err);
// Token rejected → re-auth once and retry
if (msg.includes('401') || msg.includes('403')) {
token = await getToken(true);
raw = await callOnce(token);
} else {
throw err;
}
}
if (!Array.isArray(raw)) return [];
return raw.map(s => mapNdSong(s as Record<string, unknown>));
}
/** Drop the cached token — call when the active server changes. */
export function ndClearTokenCache(): void {
cachedToken = null;
}
+129
View File
@@ -0,0 +1,129 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin';
export type SmartRuleOperator =
| 'is'
| 'isNot'
| 'contains'
| 'notContains'
| 'startsWith'
| 'endsWith'
| 'gt'
| 'lt'
| 'inTheRange';
export interface SmartRuleCondition {
field: string;
operator: SmartRuleOperator;
value: string | number | boolean | [number, number];
}
export interface NdSmartPlaylist {
id: string;
name: string;
songCount: number;
duration?: number;
rules?: Record<string, unknown>;
sync?: boolean;
updatedAt?: string;
}
function parseNdSmartPlaylist(raw: unknown, fallback: Partial<NdSmartPlaylist> = {}): NdSmartPlaylist {
const o = (raw as Record<string, unknown>) ?? {};
return {
id: String(o.id ?? fallback.id ?? ''),
name: String(o.name ?? fallback.name ?? ''),
songCount: Number(o.songCount ?? fallback.songCount ?? 0),
duration: typeof o.duration === 'number' ? o.duration : fallback.duration,
rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record<string, unknown>) : fallback.rules,
sync: typeof o.sync === 'boolean' ? o.sync : fallback.sync,
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : fallback.updatedAt,
};
}
let authCache: {
key: string;
token: string;
expiresAt: number;
} | null = null;
async function getNavidromeAuth(): Promise<{ serverUrl: string; token: string }> {
const s = useAuthStore.getState();
const server = s.getActiveServer();
const serverUrl = s.getBaseUrl();
if (!serverUrl || !server?.username || !server?.password) {
throw new Error('No active server credentials');
}
const key = `${serverUrl}|${server.username}|${server.password}`;
if (authCache && authCache.key === key && Date.now() < authCache.expiresAt) {
return { serverUrl, token: authCache.token };
}
const login = await ndLogin(serverUrl, server.username, server.password);
authCache = {
key,
token: login.token,
expiresAt: Date.now() + 10 * 60 * 1000,
};
return { serverUrl, token: login.token };
}
function conditionToRule(c: SmartRuleCondition): Record<string, unknown> {
return { [c.operator]: { [c.field]: c.value } };
}
export function buildSmartRules(conditions: SmartRuleCondition[], opts?: { limit?: number; sort?: string }) {
const all = conditions.map(conditionToRule);
const rules: Record<string, unknown> = { all };
if (typeof opts?.limit === 'number' && opts.limit > 0) rules.limit = opts.limit;
if (opts?.sort) rules.sort = opts.sort;
return rules;
}
export async function ndListSmartPlaylists(): Promise<NdSmartPlaylist[]> {
const { serverUrl, token } = await getNavidromeAuth();
const raw = await invoke<unknown>('nd_list_playlists', { serverUrl, token, smart: true });
const list = Array.isArray(raw)
? raw
: (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items))
? (raw as { items: unknown[] }).items
: [];
return list.map((v) => parseNdSmartPlaylist(v));
}
export async function ndCreateSmartPlaylist(name: string, rules: Record<string, unknown>, sync = true): Promise<NdSmartPlaylist> {
const { serverUrl, token } = await getNavidromeAuth();
const raw = await invoke<unknown>('nd_create_playlist', {
serverUrl,
token,
body: { name, rules, sync },
});
return parseNdSmartPlaylist(raw, { name, rules, sync });
}
export async function ndUpdateSmartPlaylist(
id: string,
name: string,
rules: Record<string, unknown>,
sync = true,
): Promise<NdSmartPlaylist> {
const { serverUrl, token } = await getNavidromeAuth();
const raw = await invoke<unknown>('nd_update_playlist', {
serverUrl,
token,
id,
body: { name, rules, sync },
});
return parseNdSmartPlaylist(raw, { id, name, rules, sync });
}
export async function ndGetSmartPlaylist(id: string): Promise<NdSmartPlaylist> {
const { serverUrl, token } = await getNavidromeAuth();
const raw = await invoke<unknown>('nd_get_playlist', { serverUrl, token, id });
return parseNdSmartPlaylist(raw, { id });
}
export async function ndDeletePlaylist(id: string): Promise<void> {
const { serverUrl, token } = await getNavidromeAuth();
await invoke('nd_delete_playlist', { serverUrl, token, id });
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Orbit shared-session state types.
*
* The canonical state blob lives in the comment field of a dedicated
* server-side playlist (`__psyorbit_[sid]__`). Host writes, guests read.
* Per-user "outbox" playlists (`__psyorbit_[sid]_from_[username]__`)
* carry suggestions + heartbeats the other way.
*
* This file is types + a few pure helpers only no network, no store.
*/
/** Bump whenever the on-wire schema changes incompatibly. */
export const ORBIT_STATE_VERSION = 3 as const;
/** Prefix for the canonical session playlist name. Append an 8-hex session id. */
export const ORBIT_PLAYLIST_PREFIX = '__psyorbit_';
/** Full canonical session playlist name for a given session id. */
export function orbitSessionPlaylistName(sessionId: string): string {
return `${ORBIT_PLAYLIST_PREFIX}${sessionId}__`;
}
/** Full per-user outbox playlist name. Host reads these, guests own one. */
export function orbitOutboxPlaylistName(sessionId: string, username: string): string {
return `${ORBIT_PLAYLIST_PREFIX}${sessionId}_from_${username}__`;
}
/** One queued/current track + who added it, for attribution. */
export interface OrbitQueueItem {
trackId: string;
/** Navidrome username of the participant who suggested this track. */
addedBy: string;
/** Wall-clock ms when the host consumed this track from its originator's outbox. */
addedAt: number;
}
/** One participant's presence record. */
export interface OrbitParticipant {
user: string;
/** Wall-clock ms when the host first registered this participant. */
joinedAt: number;
/** Wall-clock ms of the participant's most recent outbox heartbeat. */
lastHeartbeat: number;
}
/**
* The canonical session state exactly what's serialised into the
* session playlist's comment field. Keep lean; the comment has a ~4 KB
* self-imposed budget.
*/
export interface OrbitState {
v: typeof ORBIT_STATE_VERSION;
/** Session id (8 hex chars). */
sid: string;
/** Navidrome username of the host. */
host: string;
/** Human-readable session name set by the host at start. */
name: string;
/** Epoch ms when the session was created. */
started: number;
/** Host-configurable cap on concurrent participants. */
maxUsers: number;
/** Currently-playing track (host's playback), or null when stopped. */
currentTrack: OrbitQueueItem | null;
/** Host's live play/pause state. */
isPlaying: boolean;
/** Host's last reported playback position in ms. */
positionMs: number;
/** Wall-clock ms of the `positionMs` snapshot, for drift calculation. */
positionAt: number;
/** Upcoming queue (not including `currentTrack`). */
queue: OrbitQueueItem[];
/**
* Snapshot of the host's actual upcoming play queue (everything after
* `queueIndex`), capped at `ORBIT_PLAY_QUEUE_LIMIT` to fit the state-blob
* byte budget. Used by the guest view so guests see what's next in the
* host's player rather than just the suggestions backlog. `addedBy`
* carries the original suggester when known, otherwise the host.
*/
playQueue?: { trackId: string; addedBy: string }[];
/**
* Total length of the host's upcoming play queue, even when `playQueue`
* was truncated. Lets the guest UI render a "+ N more" hint.
*/
playQueueTotal?: number;
/** Epoch ms of the last queue shuffle. */
lastShuffle: number;
/** Currently-present participants (excluding the host). */
participants: OrbitParticipant[];
/** Usernames blocked from re-joining this session. */
kicked: string[];
/**
* Soft-removed users short-lived markers (TTL `ORBIT_REMOVED_TTL_MS`)
* so the affected guest's next poll surfaces a "you were removed" modal.
* Unlike `kicked`, the user is NOT blocked from re-joining via the
* invite link. Aged out by the host's sweep tick.
*/
removed?: { user: string; at: number }[];
/** Set when the host has ended the session; guests should exit on next poll. */
ended?: boolean;
/** Host-settable session rules; absent on older clients — treat missing as all-defaults. */
settings?: OrbitSettings;
/**
* Usernames muted by the host: their outbox is still polled (so heartbeats
* keep them visible as participants) but new track suggestions are dropped
* before they reach the approval list. Symmetric host can re-enable.
*/
suggestionBlocked?: string[];
}
/**
* Host-configurable rules. All default to `true`, i.e. the feature runs
* "all on" for new sessions. Toggled via the Orbit-bar settings popover.
*/
/** Minute presets offered to the host in the Orbit settings popover. */
export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const;
export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number];
export interface OrbitSettings {
/** Guest suggestions go straight into the host's play queue. */
autoApprove: boolean;
/** Whether the auto-shuffle cycle runs at all. */
autoShuffle: boolean;
/**
* Minutes between each auto-shuffle cycle. Must be one of
* `ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN`. Older sessions that predate this
* field fall back to 15 via `effectiveShuffleIntervalMs`.
*/
shuffleIntervalMin?: OrbitShuffleIntervalMin;
}
export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
// Off by default — host decides per suggestion via the approval list.
autoApprove: false,
autoShuffle: true,
shuffleIntervalMin: 15,
};
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
export interface OrbitOutboxMeta {
/** Wall-clock ms of this heartbeat. */
ts: number;
}
/** Our self-imposed limit on serialised `OrbitState`. Drop oldest non-essential fields if exceeded. */
export const ORBIT_STATE_MAX_BYTES = 4096;
/** Default value of `OrbitState.maxUsers` when the host hasn't picked one. */
export const ORBIT_DEFAULT_MAX_USERS = 10;
/**
* Hard cap on `playQueue` length. ~30 tracks × ~50 bytes each 1.5 KB,
* leaving room for the rest of the state blob under `ORBIT_STATE_MAX_BYTES`.
* Excess upcoming tracks are surfaced via the `playQueueTotal` count so the
* guest UI can show a "+ N more" hint instead of pretending there's nothing.
*/
export const ORBIT_PLAY_QUEUE_LIMIT = 30;
/**
* Build a fresh state blob for a brand-new session. Used by the host on start.
*/
export function makeInitialOrbitState(args: {
sid: string;
host: string;
name: string;
maxUsers?: number;
}): OrbitState {
const now = Date.now();
return {
v: ORBIT_STATE_VERSION,
sid: args.sid,
host: args.host,
name: args.name,
started: now,
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS,
currentTrack: null,
isPlaying: false,
positionMs: 0,
positionAt: now,
queue: [],
lastShuffle: now,
participants: [],
kicked: [],
removed: [],
suggestionBlocked: [],
playQueue: [],
playQueueTotal: 0,
settings: { ...ORBIT_DEFAULT_SETTINGS },
};
}
/**
* Validate + parse an incoming state blob (untrusted JSON from the playlist
* comment). Returns null on structural mismatch or schema-version drift.
*/
export function parseOrbitState(raw: unknown): OrbitState | null {
if (!raw || typeof raw !== 'object') return null;
const s = raw as Partial<OrbitState>;
if (s.v !== ORBIT_STATE_VERSION) return null;
if (typeof s.sid !== 'string' || typeof s.host !== 'string') return null;
if (typeof s.name !== 'string' || typeof s.started !== 'number') return null;
if (typeof s.maxUsers !== 'number' || typeof s.isPlaying !== 'boolean') return null;
if (typeof s.positionMs !== 'number' || typeof s.positionAt !== 'number') return null;
if (!Array.isArray(s.queue) || !Array.isArray(s.participants) || !Array.isArray(s.kicked)) return null;
if (typeof s.lastShuffle !== 'number') return null;
// currentTrack can be null or an object — no deeper validation here; the
// producer is our own code and an item with missing fields would only hurt
// the attribution UI, not correctness.
// `removed` is optional (older hosts won't write it); coerce to [] if absent or malformed.
if (!Array.isArray(s.removed)) s.removed = [];
// `suggestionBlocked` is optional too — older hosts predate the mute feature.
if (!Array.isArray(s.suggestionBlocked)) s.suggestionBlocked = [];
// `playQueue` / `playQueueTotal` are optional (older hosts won't write them).
if (!Array.isArray(s.playQueue)) s.playQueue = [];
if (typeof s.playQueueTotal !== 'number') s.playQueueTotal = (s.playQueue?.length ?? 0);
return s as OrbitState;
}
/** Quickly derive the host's estimated live playback position on a guest. */
export function estimateLivePosition(state: OrbitState, nowMs: number): number {
if (!state.isPlaying) return state.positionMs;
return state.positionMs + (nowMs - state.positionAt);
}
+87 -15
View File
@@ -9,6 +9,8 @@ import {
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
const SUBSONIC_CLIENT = `psysonic/${version}`;
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
const buf = new Uint8Array(8);
@@ -20,7 +22,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/${version}`, f: 'json' };
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
}
export function getClient() {
@@ -71,6 +73,8 @@ export interface SubsonicAlbum {
created?: string;
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
userRating?: number;
/** OpenSubsonic: true when the album is tagged as a compilation. */
isCompilation?: boolean;
}
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
@@ -78,6 +82,8 @@ export interface SubsonicOpenArtistRef {
id?: string;
name?: string;
userRating?: number;
/** Navidrome / alternate OpenSubsonic payloads (same meaning as `userRating`). */
rating?: number;
}
export interface SubsonicSong {
@@ -118,6 +124,14 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
contributors?: Array<{
role: string;
subRole?: string;
artist: { id?: string; name: string };
}>;
}
export interface InternetRadioStation {
@@ -285,7 +299,7 @@ export async function pingWithCredentials(
const salt = secureRandomSalt();
const token = md5(password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' },
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
@@ -297,7 +311,8 @@ export async function pingWithCredentials(
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
} catch {
} catch (err) {
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
return { ok: false };
}
}
@@ -487,6 +502,30 @@ export async function getRandomSongs(size = 50, genre?: string, timeout = 15000)
return data.randomSongs?.song ?? [];
}
export interface RandomSongsFilters {
size?: number;
genre?: string;
fromYear?: number;
toYear?: number;
}
/** Extended random song fetch with server-side year/genre filtering. */
export async function getRandomSongsFiltered(
filters: RandomSongsFilters,
timeout = 15000,
): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = {
size: filters.size ?? 50,
_t: Date.now(),
...libraryFilterParams(),
};
if (filters.genre) params.genre = filters.genre;
if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear;
if (typeof filters.toYear === 'number') params.toYear = filters.toYear;
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 });
@@ -524,6 +563,17 @@ function parseEntityUserRating(v: unknown): number | undefined {
return n;
}
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
export function parseSubsonicEntityStarRating(entity: {
userRating?: unknown;
rating?: unknown;
}): number | undefined {
return parseEntityUserRating(entity.userRating ?? entity.rating);
}
/** Bump when rating parse keys change so stale cache entries are not reused. */
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
@@ -534,7 +584,7 @@ export async function prefetchArtistUserRatings(
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`artist:${id}`);
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
@@ -547,8 +597,8 @@ export async function prefetchArtistUserRatings(
const id = uncached[i];
try {
const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating);
setCachedRating(`artist:${id}`, r);
const r = parseSubsonicEntityStarRating(artist);
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
@@ -570,7 +620,7 @@ export async function prefetchAlbumUserRatings(
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`album:${id}`);
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
@@ -583,8 +633,8 @@ export async function prefetchAlbumUserRatings(
const id = uncached[i];
try {
const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating);
setCachedRating(`album:${id}`, r);
const r = parseSubsonicEntityStarRating(album);
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
@@ -890,6 +940,23 @@ export async function search(query: string, options?: { albumCount?: number; art
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
/**
* Song-only paginated search3. Tolerates empty query Navidrome returns all songs
* ordered by title in that case; strict Subsonic implementations may return nothing.
* Caller handles empty results gracefully (Tracks page falls back to its random pool).
*/
export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise<SubsonicSong[]> {
const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', {
query,
artistCount: 0,
albumCount: 0,
songCount,
songOffset,
...libraryFilterParams(),
});
return data.searchResult3?.song ?? [];
}
export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
}
@@ -942,7 +1009,7 @@ export function buildStreamUrl(id: string): string {
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
});
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
@@ -962,7 +1029,7 @@ export function buildCoverArtUrl(id: string, size = 256): string {
const p = new URLSearchParams({
id, size: String(size),
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
});
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
}
@@ -976,15 +1043,20 @@ export function buildDownloadUrl(id: string): string {
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
});
return `${baseUrl}/rest/download.view?${p.toString()}`;
}
// ─── Playlists ────────────────────────────────────────────────
export async function getPlaylists(): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view');
return data.playlists?.playlist ?? [];
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
const all = data.playlists?.playlist ?? [];
// Orbit session + outbox playlists are technical internals. They're `public`
// so guests can reach them, which means they leak into every UI picker and
// even into the Navidrome web client. Filter them out of every UI call;
// orbit's own sweep passes `includeOrbit=true`.
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
}
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
+162
View File
@@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import PsysonicLogo from './PsysonicLogo';
const TAPS_TO_REVEAL_HINT = 10;
const TARGET_CLICKS_IN_WINDOW = 100;
const WINDOW_MS = 60_000;
/** Hardcoded About lol copy — intentionally not in locale files. */
const MSG_HINT =
'To become a developer, you need to click the Psysonic logo 100 times within one minute.';
const MSG_CONGRATS_TITLE = 'Congratulations.';
const MSG_CONGRATS_SIGN_OFF = 'Sincerely, your maintainers.';
const MSG_CONGRATS_PS = "PS: Don't forget to star the repo! ★";
/**
* About page brand row + Settings System About lol (logo taps + modal).
* Modal copy is English and hardcoded by design.
*/
export function AboutPsysonicBrandHeader({
appVersion,
aboutVersionLabel,
}: {
appVersion: string;
aboutVersionLabel: string;
}) {
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
const [idleTaps, setIdleTaps] = useState(0);
const [hintTimestamps, setHintTimestamps] = useState<number[]>([]);
const [overlayOpen, setOverlayOpen] = useState(false);
const onLogoClick = useCallback(() => {
if (phase === 'done') return;
if (phase === 'idle') {
setIdleTaps(prev => {
const next = prev + 1;
if (next >= TAPS_TO_REVEAL_HINT) queueMicrotask(() => setPhase('hint'));
return next;
});
return;
}
if (phase === 'hint') {
const now = Date.now();
setHintTimestamps(prev => {
const inWindow = prev.filter(t => t > now - WINDOW_MS);
const nextTimes = [...inWindow, now];
if (nextTimes.length >= TARGET_CLICKS_IN_WINDOW) {
queueMicrotask(() => {
setPhase('done');
setOverlayOpen(true);
});
}
return nextTimes;
});
}
}, [phase]);
const closeOverlay = useCallback(() => setOverlayOpen(false), []);
useEffect(() => {
if (!overlayOpen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow;
};
}, [overlayOpen]);
return (
<>
<div className="settings-about-header">
<button
type="button"
onClick={onLogoClick}
className="about-psysonic-logo-lol-hit"
aria-label="Psysonic"
>
<img src="/logo-psysonic.png" width={52} height={52} alt="" decoding="async" style={{ borderRadius: 14, display: 'block' }} />
</button>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{aboutVersionLabel} {appVersion}
</div>
</div>
</div>
{phase === 'hint' && !overlayOpen && (
<p
style={{
fontSize: 12,
color: 'var(--text-secondary)',
marginTop: '0.5rem',
lineHeight: 1.5,
}}
>
{MSG_HINT}
</p>
)}
{overlayOpen &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="about-psysonic-lol-title"
className="about-psysonic-lol-overlay"
>
<button
type="button"
className="about-psysonic-lol-close"
aria-label="Close"
onClick={closeOverlay}
>
<X size={26} strokeWidth={2.25} aria-hidden />
</button>
<div className="about-psysonic-lol-panel">
<div className="about-psysonic-lol-logo-slot">
<PsysonicLogo
gradientIdSuffix={modalWordmarkGradSuffix}
className="about-psysonic-lol-logo-mark"
style={{
height: 'clamp(3.25rem, 14vw, 5.75rem)',
width: 'auto',
maxWidth: 'min(100%, 420px)',
display: 'block',
}}
/>
</div>
<div className="about-psysonic-lol-copy">
<h2 id="about-psysonic-lol-title" className="about-psysonic-lol-title">
{MSG_CONGRATS_TITLE}
</h2>
<p className="about-psysonic-lol-lede">
{"We're very much looking forward to you as a developer — join us on "}
<button
type="button"
className="about-psysonic-lol-inline-link"
onClick={() => void openUrl('https://github.com/Psychotoxical/psysonic')}
>
GitHub
</button>
{' and build great features!'}
</p>
<p className="about-psysonic-lol-signoff">{MSG_CONGRATS_SIGN_OFF}</p>
<p className="about-psysonic-lol-ps">{MSG_CONGRATS_PS}</p>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
+25 -3
View File
@@ -1,8 +1,9 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, HardDriveDownload, Check } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
@@ -19,8 +20,10 @@ interface AlbumCardProps {
}
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
@@ -94,9 +97,28 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
data-tooltip={t('hero.playAlbum')}
data-tooltip-pos="top"
>
<Play size={15} fill="currentColor" />
</button>
<button
className="album-card-details-btn"
onClick={async e => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
} catch {
// Network failure — silent (toast would be too noisy for a hover action)
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
data-tooltip={t('contextMenu.enqueueAlbum')}
data-tooltip-pos="top"
>
<ListPlus size={15} />
</button>
</div>
)}
</div>
+32 -1
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react';
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
@@ -9,6 +9,8 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { useThemeStore } from '../store/themeStore';
import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
import { showToast } from '../utils/toast';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -130,6 +132,16 @@ export default function AlbumHeader({
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(' / ');
const handleShareAlbum = async () => {
try {
const ok = await copyEntityShareLink('album', info.id);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
};
return (
<>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
@@ -242,6 +254,16 @@ export default function AlbumHeader({
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
className="album-icon-btn album-icon-btn--sm"
type="button"
onClick={handleShareAlbum}
aria-label={t('albumDetail.shareAlbum')}
data-tooltip={t('albumDetail.shareAlbum')}
>
<Share2 size={16} />
</button>
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
@@ -321,6 +343,15 @@ export default function AlbumHeader({
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
type="button"
className="btn btn-ghost"
onClick={handleShareAlbum}
aria-label={t('albumDetail.shareAlbum')}
data-tooltip={t('albumDetail.shareAlbum')}
>
<Share2 size={16} />
</button>
</div>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
+11
View File
@@ -57,6 +57,8 @@ interface AlbumTrackListProps {
userRatingOverrides: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
/** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
@@ -80,6 +82,7 @@ interface TrackRowProps {
inSelectMode: boolean;
isContextMenuSong: boolean;
onPlaySong: (song: SubsonicSong) => void;
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: AlbumTrackListProps['onContextMenu'];
@@ -100,6 +103,7 @@ const TrackRow = React.memo(function TrackRow({
inSelectMode,
isContextMenuSong,
onPlaySong,
onDoubleClickSong,
onRate,
onToggleSongStar,
onContextMenu,
@@ -224,6 +228,11 @@ const TrackRow = React.memo(function TrackRow({
onPlaySong(song);
}
}}
onDoubleClick={onDoubleClickSong ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey || inSelectMode) return;
onDoubleClickSong(song);
} : undefined}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
@@ -266,6 +275,7 @@ export default function AlbumTrackList({
userRatingOverrides,
starredSongs,
onPlaySong,
onDoubleClickSong,
onRate,
onToggleSongStar,
onContextMenu,
@@ -644,6 +654,7 @@ export default function AlbumTrackList({
inSelectMode={inSelectMode}
isContextMenuSong={contextMenuSongId === song.id}
onPlaySong={onPlaySong}
onDoubleClickSong={onDoubleClickSong}
onRate={onRate}
onToggleSongStar={onToggleSongStar}
onContextMenu={onContextMenu}
+142 -19
View File
@@ -4,7 +4,7 @@ import { open } from '@tauri-apps/plugin-shell';
import { listen } from '@tauri-apps/api/event';
import { dirname } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
@@ -103,7 +103,10 @@ export default function AppUpdater() {
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
const [dlPath, setDlPath] = useState('');
const [dlError, setDlError] = useState('');
const [countdown, setCountdown] = useState<number | null>(null);
const unlistenRef = useRef<(() => void) | null>(null);
const countdownRef = useRef<number | null>(null);
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
const fetchRelease = async (preview = false) => {
try {
@@ -152,19 +155,51 @@ export default function AppUpdater() {
// Clean up download listener when component unmounts
useEffect(() => {
return () => { unlistenRef.current?.(); };
return () => {
unlistenRef.current?.();
if (countdownRef.current) window.clearInterval(countdownRef.current);
};
}, []);
if (!release || dismissed) return null;
const asset = pickAsset(release.assets);
const showAurHint = IS_LINUX && isArch;
// On macOS the Tauri Updater handles architecture, signature verification
// and in-place install — we don't need (and should not show) a DMG asset.
const useTauriUpdater = IS_MACOS;
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
const handleSkip = () => {
localStorage.setItem(SKIP_KEY, release.version);
setDismissed(true);
};
const startRestartCountdown = (seconds: number) => {
let remaining = seconds;
setCountdown(remaining);
countdownRef.current = window.setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
if (countdownRef.current) window.clearInterval(countdownRef.current);
countdownRef.current = null;
setCountdown(null);
relaunchFnRef.current?.();
} else {
setCountdown(remaining);
}
}, 1000);
};
const handleRestartNow = async () => {
if (countdownRef.current) {
window.clearInterval(countdownRef.current);
countdownRef.current = null;
}
setCountdown(null);
await relaunchFnRef.current?.();
};
const handleDownload = async () => {
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
// the minisign signature against the bundled pubkey, replaces the .app, and
@@ -175,6 +210,8 @@ export default function AppUpdater() {
setDlError('');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { relaunch } = await import('@tauri-apps/plugin-process');
relaunchFnRef.current = relaunch;
const update = await check();
if (!update) {
setDlError(t('common.updaterErrorMsg'));
@@ -192,9 +229,12 @@ export default function AppUpdater() {
setDlProgress({ bytes: downloaded, total });
} else if (event.event === 'Finished') {
setDlState('done');
// downloadAndInstall replaces the .app in place but does not exit
// the running process. Give the user a 3s countdown (with a manual
// "Restart now" button) before auto-relaunch.
startRestartCountdown(3);
}
});
// downloadAndInstall replaces the .app and relaunches automatically on macOS.
} catch (e) {
setDlError(String(e));
setDlState('error');
@@ -309,6 +349,59 @@ export default function AppUpdater() {
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{fmtBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
@@ -356,25 +449,55 @@ export default function AppUpdater() {
</div>
</div>{/* end update-modal-body */}
{/* Footer buttons */}
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
<div className="update-modal-footer">
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
{!showAurHint && asset && dlState === 'idle' && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{t('common.updaterDownloadBtn')}
</button>
{dlState === 'idle' && (
<>
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
)}
</>
)}
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
{dlState === 'done' && useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
)}
{dlState === 'done' && !useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
)}
{dlState === 'error' && (
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
)}
</div>
</div>
+3 -1
View File
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
interface Props {
@@ -9,6 +10,7 @@ interface Props {
}
export default function ArtistCardLocal({ artist }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const coverId = artist.coverArt || artist.id;
// buildCoverArtUrl generates a new crypto salt on every call — must be
@@ -38,7 +40,7 @@ export default function ArtistCardLocal({ artist }: Props) {
<span className="artist-card-name">{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
{t('artists.albumCount', { count: artist.albumCount })}
</span>
)}
</div>
+15 -1
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
import { Disc3, Search, Music4, AudioLines, MoreHorizontal } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import MobileSearchOverlay from './MobileSearchOverlay';
import MobileMoreOverlay from './MobileMoreOverlay';
const NAV_ITEMS = [
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
@@ -16,6 +17,7 @@ export default function BottomNav() {
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [searchOpen, setSearchOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
return (
<>
@@ -47,9 +49,21 @@ export default function BottomNav() {
</span>
<span className="bottom-nav-label">{t('search.title')}</span>
</button>
<button
className={`bottom-nav-item${moreOpen ? ' active' : ''}`}
onClick={() => setMoreOpen(v => !v)}
aria-label={t('sidebar.more')}
>
<span className="bottom-nav-icon-wrap">
<MoreHorizontal size={22} />
</span>
<span className="bottom-nav-label">{t('sidebar.more')}</span>
</button>
</nav>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
{moreOpen && <MobileMoreOverlay onClose={() => setMoreOpen(false)} />}
</>
);
}
+58 -8
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
@@ -7,22 +7,69 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
}
/**
* Returns a shared, refcounted object URL for a cached image. Multiple
* consumers of the same cacheKey see the exact same URL string, so the
* browser's decoded-image cache hits across instances critical on
* Chromium/WebView2 (Windows), which keys decode results by URL.
*
* @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('');
// Synchronously acquire on first render when the blob is already hot. This
// makes the very first <img src> a blob URL, avoiding a fetchUrl→blobUrl
// swap that would trigger a redundant network request and decode pass.
const [resolved, setResolved] = useState(() => fetchUrl ? (acquireUrl(cacheKey) ?? '') : '');
// Tracks whichever cacheKey we currently hold a refcount on, so we know
// exactly what to release on cleanup or when keys change.
const ownedKeyRef = useRef<string | null>(resolved ? cacheKey : null);
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
const controller = new AbortController();
const release = () => {
if (ownedKeyRef.current) {
releaseUrl(ownedKeyRef.current);
ownedKeyRef.current = null;
}
};
if (!fetchUrl) {
release();
setResolved('');
return;
}
// Lazy initializer (or a previous run) already acquired the right key.
if (ownedKeyRef.current === cacheKey) return release;
// Different key than we're currently holding: drop the old one.
release();
// Fast path: blob is hot in memory → grab the shared URL synchronously.
const sync = acquireUrl(cacheKey);
if (sync) {
ownedKeyRef.current = cacheKey;
setResolved(sync);
return release;
}
// Slow path: fetch (or read from IDB), then acquire.
setResolved('');
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
if (!controller.signal.aborted) setResolved(url);
const controller = new AbortController();
getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => {
if (controller.signal.aborted || !blob) return;
const url = acquireUrl(cacheKey);
if (!url) return;
ownedKeyRef.current = cacheKey;
setResolved(url);
});
return () => { controller.abort(); };
return () => {
controller.abort();
release();
};
}, [fetchUrl, cacheKey]);
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
}
@@ -43,7 +90,10 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
}, []);
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
// fallbackToFetch=false: avoid the fetchUrl→blobUrl src swap, which causes the browser
// to start a server fetch, then abort it when we replace src with the blob URL —
// visible in DevTools as a flood of "Pending / 0 B" requests on Chromium/WebView2.
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey, false);
const [loaded, setLoaded] = useState(false);
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
+2 -9
View File
@@ -5,6 +5,7 @@ import { Sparkles } from 'lucide-react';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useAuthStore } from '../store/authStore';
import { findChangelogReleaseEntry } from '../utils/changelogReleaseMatch';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
@@ -29,15 +30,7 @@ export default function ChangelogModal({ onClose }: Props) {
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 currentVersionData = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
+83
View File
@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface ConfirmModalProps {
open: boolean;
title: string;
message: string;
confirmLabel: string;
/**
* Cancel button label. Omit (together with `onCancel`) to render the
* modal as a single-button info dialog Esc / outside-click / X then
* also resolve via `onConfirm`.
*/
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel?: () => void;
}
export default function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
danger,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const dismiss = onCancel ?? onConfirm;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') dismiss();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, dismiss, onConfirm]);
if (!open) return null;
const confirmStyle = danger
? { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }
: undefined;
return createPortal(
<div
className="modal-overlay"
onClick={dismiss}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '380px' }}
>
<button className="modal-close" onClick={dismiss} aria-label={cancelLabel ?? confirmLabel}>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{message}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
{cancelLabel && onCancel && (
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
{cancelLabel}
</button>
)}
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm} autoFocus={!cancelLabel}>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore, type ServerProfile } from '../store/authStore';
import { switchActiveServer } from '../utils/switchActiveServer';
import { showToast } from '../utils/toast';
import { serverListDisplayLabel } from '../utils/serverDisplayName';
interface Props {
status: ConnectionStatus;
@@ -43,7 +44,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const goServerSettings = () => {
setMenuOpen(false);
navigate('/settings', { state: { tab: 'server' } });
navigate('/settings', { state: { tab: 'servers' } });
};
const onTriggerClick = () => {
@@ -123,7 +124,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId !== null;
const labelText = (srv.name || srv.url).trim() || srv.url;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
+261 -70
View File
@@ -1,5 +1,12 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack } from 'lucide-react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useOrbitStore } from '../store/orbitStore';
import {
suggestOrbitTrack,
hostEnqueueToOrbit,
evaluateOrbitSuggestGate,
OrbitSuggestBlockedError,
} from '../utils/orbit';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
@@ -16,6 +23,25 @@ import { invoke } from '@tauri-apps/api/core';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import type { EntityShareKind } from '../utils/shareLink';
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
import { useConfirmModalStore } from '../store/confirmModalStore';
/** Ask user before re-adding songs to a playlist when *all* selected ids are
* already present. Returns true caller should append them as duplicates,
* false caller should keep today's silent-skip toast behavior. */
async function confirmAddAllDuplicates(
playlistName: string,
count: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): Promise<boolean> {
return useConfirmModalStore.getState().request({
title: t('playlists.duplicateConfirmTitle'),
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
confirmLabel: t('playlists.duplicateConfirmAction'),
cancelLabel: t('common.cancel'),
});
}
function sanitizeFilename(name: string): string {
return name
@@ -25,6 +51,13 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download';
}
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
const SMART_PLAYLIST_PREFIX = 'psy-smart-';
function isSmartPlaylistName(name: string | undefined | null): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX);
}
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
function shuffleArray<T>(arr: T[]): T[] {
const result = [...arr];
@@ -57,16 +90,18 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sort playlists by recent usage
// Sort playlists by recent usage (exclude app smart playlists — not editable as normal lists)
const playlists = useMemo(() => {
return [...storePlaylists].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;
});
return [...storePlaylists]
.filter(p => !isSmartPlaylistName(p.name))
.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;
});
}, [storePlaylists, recentIds]);
// Flip submenu left if it would overflow the right edge of the viewport
@@ -92,10 +127,17 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
if (newIds.length > 0) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name }));
touchPlaylist(pl.id);
} else {
showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info');
const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t);
if (accepted) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]);
showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info');
touchPlaylist(pl.id);
} else {
showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info');
}
}
touchPlaylist(pl.id);
} catch {
showToast(t('playlists.addError'), 3000, 'error');
}
@@ -255,34 +297,42 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
}
}
if (newIds.length > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
}
// Show detailed toast notification
const totalSongs = songIds.length;
const addedCount = newIds.length;
const duplicateCount = duplicateIds.length;
if (addedCount === 0 && duplicateCount > 0) {
showToast(
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
4000,
'info'
);
if (addedCount > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
if (duplicateCount > 0) {
showToast(
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
4000,
'info'
);
} else {
showToast(
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
3000,
'info'
);
}
} else if (duplicateCount > 0) {
showToast(
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
4000,
'info'
);
} else {
showToast(
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
3000,
'info'
);
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
if (accepted) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
touchPlaylist(pl.id);
showToast(
t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }),
3000,
'info'
);
} else {
showToast(
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
4000,
'info'
);
}
}
} catch (err) {
showToast(t('playlists.addError'), 4000, 'error');
@@ -328,7 +378,9 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
// Sort playlists from store (no fetch needed, prevents flash)
const playlists = useMemo(() => {
return [...storePlaylists].sort((a, b) => a.name.localeCompare(b.name));
return [...storePlaylists]
.filter(p => !isSmartPlaylistName(p.name))
.sort((a, b) => a.name.localeCompare(b.name));
}, [storePlaylists]);
useLayoutEffect(() => {
@@ -489,33 +541,42 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
}
}
if (newIds.length > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
}
// Show detailed toast notification
const addedCount = newIds.length;
const duplicateCount = duplicateIds.length;
if (addedCount === 0 && duplicateCount > 0) {
showToast(
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
4000,
'info'
);
if (addedCount > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
if (duplicateCount > 0) {
showToast(
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
4000,
'info'
);
} else {
showToast(
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
3000,
'info'
);
}
} else if (duplicateCount > 0) {
showToast(
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
4000,
'info'
);
} else {
showToast(
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
3000,
'info'
);
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
if (accepted) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
touchPlaylist(pl.id);
showToast(
t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }),
3000,
'info'
);
} else {
showToast(
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
4000,
'info'
);
}
}
} catch (err) {
showToast(t('playlists.addError'), 4000, 'error');
@@ -537,7 +598,9 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
useEffect(() => {
getPlaylists().then((all) => {
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
setPlaylists(
all.filter(p => !isSmartPlaylistName(p.name)).sort((a, b) => a.name.localeCompare(b.name)),
);
}).catch(() => {});
}, []);
@@ -660,9 +723,11 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
const [flipUp, setFlipUp] = useState(false);
const storePlaylists = usePlaylistStore((s) => s.playlists);
// Filter out the current playlist from the list
// Filter out the current playlist and smart playlists from the list
const allPlaylists = useMemo(() => {
return storePlaylists.filter((p) => p.id !== playlist.id);
return storePlaylists.filter(
(p) => p.id !== playlist.id && !isSmartPlaylistName(p.name),
);
}, [storePlaylists, playlist.id]);
useLayoutEffect(() => {
@@ -805,10 +870,12 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
const [flipUp, setFlipUp] = useState(false);
const storePlaylists = usePlaylistStore((s) => s.playlists);
// Filter out the selected playlists from the list
// Filter out the selected playlists and smart playlists from the list
const allPlaylists = useMemo(() => {
const selectedIds = new Set(playlists.map(p => p.id));
return storePlaylists.filter((p) => !selectedIds.has(p.id));
return storePlaylists.filter(
(p) => !selectedIds.has(p.id) && !isSmartPlaylistName(p.name),
);
}, [storePlaylists, playlists]);
useLayoutEffect(() => {
@@ -960,6 +1027,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
export default function ContextMenu() {
const { t } = useTranslation();
const orbitRole = useOrbitStore(s => s.role);
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({
contextMenu: s.contextMenu,
@@ -1079,6 +1147,22 @@ export default function ContextMenu() {
});
}, [contextMenu.isOpen]);
// Outside-click closes the menu without occluding the underlying UI. The
// previous implementation rendered a transparent fullscreen backdrop, which
// also blocked right-clicks from reaching elements *under* it — so users
// couldn't reposition the menu by right-clicking another row.
useEffect(() => {
if (!contextMenu.isOpen) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node | null;
if (!target) return;
if (menuRef.current?.contains(target)) return;
closeContextMenu();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [contextMenu.isOpen, closeContextMenu]);
useEffect(() => {
if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return;
let cancelled = false;
@@ -1265,6 +1349,12 @@ export default function ContextMenu() {
await action();
};
const copyShareLink = useCallback(async (kind: EntityShareKind, id: string) => {
const ok = await copyEntityShareLink(kind, id);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}, [t]);
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
if (seedTrack) {
// Start playback immediately based on current state
@@ -1401,11 +1491,6 @@ export default function ContextMenu() {
return (
<>
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
onMouseDown={() => closeContextMenu()}
/>
<div
ref={menuRef}
className="context-menu animate-fade-in"
@@ -1435,6 +1520,38 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{orbitRole === 'guest' && (() => {
const muted = evaluateOrbitSuggestGate().reason === 'muted';
return (
<div
className={`context-menu-item${muted ? ' is-disabled' : ''}`}
{...(muted ? { 'data-tooltip': t('orbit.suggestBlockedMuted') } : {})}
onClick={() => handleAction(() => {
if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; }
suggestOrbitTrack(song.id)
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
.catch(err => {
if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') {
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
} else {
showToast(t('orbit.ctxSuggestFailed'), 3000, 'error');
}
});
})}
>
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
</div>
);
})()}
{orbitRole === 'host' && (
<div className="context-menu-item" onClick={() => handleAction(() => {
hostEnqueueToOrbit(song.id)
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
})}>
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
</div>
)}
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
@@ -1515,6 +1632,9 @@ export default function ContextMenu() {
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
@@ -1564,6 +1684,38 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{orbitRole === 'guest' && (() => {
const muted = evaluateOrbitSuggestGate().reason === 'muted';
return (
<div
className={`context-menu-item${muted ? ' is-disabled' : ''}`}
{...(muted ? { 'data-tooltip': t('orbit.suggestBlockedMuted') } : {})}
onClick={() => handleAction(() => {
if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; }
suggestOrbitTrack(song.id)
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
.catch(err => {
if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') {
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
} else {
showToast(t('orbit.ctxSuggestFailed'), 3000, 'error');
}
});
})}
>
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
</div>
);
})()}
{orbitRole === 'host' && (
<div className="context-menu-item" onClick={() => handleAction(() => {
hostEnqueueToOrbit(song.id)
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
})}>
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
</div>
)}
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
@@ -1627,6 +1779,9 @@ export default function ContextMenu() {
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
@@ -1649,6 +1804,25 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(songToTrack);
if (tracks.length === 0) return;
if (!currentTrack) {
playTrack(tracks[0], tracks);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1);
})}>
<ChevronRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(album.id);
enqueue(albumData.songs.map(songToTrack));
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
@@ -1679,6 +1853,9 @@ export default function ContextMenu() {
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
@@ -1751,6 +1928,14 @@ export default function ContextMenu() {
{t('contextMenu.selectedAlbums', { count: albums.length })}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(async () => {
// Parallel — Navidrome handles concurrent getAlbum requests fine.
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
const allTracks = results.flatMap(r => r.songs.map(songToTrack));
enqueue(allTracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
@@ -1787,6 +1972,9 @@ export default function ContextMenu() {
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('artist', artist.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
@@ -1975,6 +2163,9 @@ export default function ContextMenu() {
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
+115 -68
View File
@@ -1,19 +1,24 @@
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { getCachedUrl } from '../utils/imageCache';
import { getCachedBlob } from '../utils/imageCache';
import { extractCoverColors } from '../utils/dynamicColors';
import { useTranslation } from 'react-i18next';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
import { SpringScroller, targetForFraction } from '../utils/springScroll';
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -47,23 +52,18 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
const [activeIdx, setActiveIdx] = useState(-1);
const activeIdxRef = useRef(-1);
const containerRef = useRef<HTMLDivElement>(null);
const springRef = useRef<SpringScroller | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollerRef = useRef<EaseScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef({ line: -1, word: -1 });
const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Create/destroy the SpringScroller when the container mounts.
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (el) {
springRef.current = new SpringScroller(el, 0.1, 0.78);
} else {
springRef.current?.stop();
springRef.current = null;
}
containerRef.current = el;
scrollerRef.current?.stop();
scrollerRef.current = el ? new EaseScroller(el) : null;
}, []);
// Reset everything on track change.
@@ -73,7 +73,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
prevWord.current = { line: -1, word: -1 };
activeIdxRef.current = -1;
setActiveIdx(-1);
springRef.current?.jump(0);
scrollerRef.current?.jump(0);
}, [currentTrack?.id]);
// Subscribe to playback time — only triggers React setState when line changes.
@@ -92,13 +92,13 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]);
// Spring-scroll active line to ~35% from the top of the container.
// Ease-scroll active line to ~35% from the top of the container.
useEffect(() => {
if (activeIdx < 0 || isUserScroll.current) return;
const el = lineRefs.current[activeIdx];
const box = containerRef.current;
if (!el || !box || !springRef.current) return;
springRef.current.scrollTo(targetForFraction(box, el, 0.35));
if (!el || !box || !scrollerRef.current) return;
scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35));
}, [activeIdx]);
// Word-sync: imperative DOM updates, zero React re-renders per tick.
@@ -134,8 +134,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
}, [useWords, wordLines]);
const handleUserScroll = useCallback(() => {
// Stop spring animation so it doesn't fight the user's scroll.
springRef.current?.stop();
scrollerRef.current?.stop();
isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
@@ -149,9 +148,11 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
if (!currentTrack || loading) return null;
const isPlain = !hasSynced && !!plainLyrics;
return (
<div
className="fsa-lyrics-container"
className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
ref={setContainerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
@@ -432,47 +433,52 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const playedRef = useRef<HTMLDivElement>(null);
const bufRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const isDraggingRef = useRef(false);
const pendingSeekRef = useRef<number | null>(null);
const previewSeek = useCallback((progress: number) => {
const s = usePlayerStore.getState();
const p = Math.max(0, Math.min(1, progress));
pendingSeekRef.current = p;
if (timeRef.current) {
const previewTime = duration > 0 ? p * duration : s.currentTime;
timeRef.current.textContent = formatTime(previewTime);
}
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(p);
}, [duration]);
const commitSeek = useCallback(() => {
const pending = pendingSeekRef.current;
if (pending === null) return;
pendingSeekRef.current = null;
seek(pending);
}, [seek]);
useEffect(() => {
// Cache the last applied values so we can skip DOM writes when nothing
// changed. The store subscription fires on every state change (queue,
// volume, currentTrack…), not just progress, so without this guard we
// were writing identical width/textContent strings dozens of times per
// second — each one triggers a style invalidation in WebKitGTK.
let lastTime = -1;
let lastPct = -1;
let lastBufW = -1;
let lastProg = -1;
const s = usePlayerStore.getState();
const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(s.progress);
const apply = (s: ReturnType<typeof usePlayerStore.getState>) => {
const pct = s.progress * 100;
const bufW = Math.max(pct, s.buffered * 100);
const sec = s.currentTime | 0;
if (sec !== lastTime) {
lastTime = sec;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
}
if (pct !== lastPct) {
lastPct = pct;
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
}
if (bufW !== lastBufW) {
lastBufW = bufW;
if (bufRef.current) bufRef.current.style.width = `${bufW}%`;
}
if (s.progress !== lastProg) {
lastProg = s.progress;
if (inputRef.current) inputRef.current.value = String(s.progress);
}
};
apply(usePlayerStore.getState());
return usePlayerStore.subscribe(apply);
return usePlayerStore.subscribe(state => {
if (isDraggingRef.current) return;
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
if (playedRef.current) playedRef.current.style.width = `${p}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(state.progress);
});
}, []);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
[seek]
(e: React.ChangeEvent<HTMLInputElement>) => {
previewSeek(parseFloat(e.target.value));
},
[previewSeek]
);
return (
@@ -490,6 +496,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
type="range" min={0} max={1} step={0.001}
defaultValue={0}
onChange={handleSeek}
onMouseDown={() => { isDraggingRef.current = true; }}
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
onTouchStart={() => { isDraggingRef.current = true; }}
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
onPointerDown={() => { isDraggingRef.current = true; }}
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
onKeyUp={commitSeek}
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
aria-label="seek"
/>
</div>
@@ -502,8 +516,9 @@ interface FsLyricsMenuProps {
open: boolean;
onClose: () => void;
accentColor: string | null;
triggerRef?: React.RefObject<HTMLElement | null>;
}
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: FsLyricsMenuProps) {
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: FsLyricsMenuProps) {
const { t } = useTranslation();
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
@@ -512,13 +527,16 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
const panelRef = useRef<HTMLDivElement>(null);
// Close on click outside the panel or on Escape.
// setTimeout(0) defers listener registration past the current click cycle
// so the button click that opens the panel doesn't immediately close it.
// Ignore clicks on the trigger button so re-clicking it toggles normally
// instead of outside-handler closing + click re-opening.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const onMouse = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (triggerRef?.current?.contains(target)) return;
onClose();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
@@ -527,7 +545,7 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onMouse);
};
}, [open, onClose]);
}, [open, onClose, triggerRef]);
if (!open) return null;
@@ -569,14 +587,40 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
});
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
const FsPlayBtn = memo(function FsPlayBtn() {
const FsPlayBtn = memo(function FsPlayBtn({
controlsAnchorRef,
}: {
controlsAnchorRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
return (
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
<>
<span ref={playSlotRef} className="playback-transport-play-wrap">
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} className="playback-schedule-badge--fs" />
<button
type="button"
className="fs-btn fs-btn-play"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--fs`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={12} strokeWidth={2.5} />
: <Sunrise size={12} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
</span>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
</>
);
});
@@ -696,12 +740,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
if (!nextCoverArt) return;
const url = buildCoverArtUrl(nextCoverArt, 300);
const key = coverArtCacheKey(nextCoverArt, 300);
getCachedUrl(url, key).catch(() => {});
getCachedBlob(url, key).catch(() => {});
}, [nextCoverArt]);
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
const fsControlsRef = useRef<HTMLDivElement>(null);
// Idle-fade system — hides controls after 3 s of inactivity
const [isIdle, setIsIdle] = useState(false);
@@ -808,14 +854,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
)}
{/* Controls */}
<div className="fs-controls">
<div className="fs-controls" ref={fsControlsRef}>
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn />
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
@@ -838,8 +884,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
</button>
)}
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
<button
ref={lyricsMenuTriggerRef}
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
+160 -103
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Filter, X } from 'lucide-react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getGenres } from '../api/subsonic';
@@ -13,8 +14,10 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
const [open, setOpen] = useState(false);
const [genres, setGenres] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -23,124 +26,178 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
);
}, []);
// close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selectedSet = useMemo(() => new Set(selected), [selected]);
// sync open state with selection
useEffect(() => {
if (selected.length > 0) setOpen(true);
}, [selected]);
// Selected on top, then alphabetical (stable for comfortable scanning).
const sortedGenres = useMemo(() => {
const arr = [...genres];
arr.sort((a, b) => {
const sa = selectedSet.has(a) ? 0 : 1;
const sb = selectedSet.has(b) ? 0 : 1;
if (sa !== sb) return sa - sb;
return a.localeCompare(b);
});
return arr;
}, [genres, selectedSet]);
const filteredOptions = genres.filter(
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
);
const filteredGenres = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sortedGenres;
return sortedGenres.filter(g => g.toLowerCase().includes(q));
}, [sortedGenres, search]);
const add = (genre: string) => {
onSelectionChange([...selected, genre]);
setSearch('');
inputRef.current?.focus();
const updatePopStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = 280;
const MAX_H = 360;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left, 8),
window.innerWidth - WIDTH - 8,
);
setPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
const remove = (genre: string) => {
onSelectionChange(selected.filter(s => s !== genre));
useLayoutEffect(() => {
if (!open) return;
updatePopStyle();
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return;
const onResize = () => updatePopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!popRef.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]);
const toggle = (genre: string) => {
if (selectedSet.has(genre)) onSelectionChange(selected.filter(s => s !== genre));
else onSelectionChange([...selected, genre]);
};
const clear = () => {
onSelectionChange([]);
setSearch('');
setOpen(false);
setDropdownOpen(false);
};
const openFilter = () => {
setOpen(true);
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
};
if (!open) {
return (
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Filter size={14} />
{t('common.filterGenre')}
</button>
);
}
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
// relatedTarget is the next focused element; if it's outside our container, handle close
const next = e.relatedTarget as Node | null;
if (containerRef.current && next && containerRef.current.contains(next)) return;
setTimeout(() => {
if (selected.length === 0) {
setOpen(false);
setSearch('');
setDropdownOpen(false);
} else {
setDropdownOpen(false);
}
}, 150);
};
const count = selected.length;
return (
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<>
<button
ref={triggerRef}
type="button"
className={`btn btn-surface${count > 0 ? ' btn-sort-active' : ''}`}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<Filter size={14} />
{t('common.filterGenre')}
{count > 0 && <span className="genre-filter-count">{count}</span>}
</button>
<div className="genre-filter-tagbox">
{selected.map(g => (
<span key={g} className="genre-filter-chip">
{g}
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
<X size={11} />
</button>
</span>
))}
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="dialog"
>
<div className="genre-filter-popover__search">
<input
ref={inputRef}
type="text"
placeholder={t('common.filterSearchGenres')}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && filteredGenres.length > 0) {
toggle(filteredGenres[0]);
}
}}
/>
</div>
<input
ref={inputRef}
className="genre-filter-input"
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
value={search}
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
onFocus={() => setDropdownOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
remove(selected[selected.length - 1]);
}
}}
/>
{dropdownOpen && filteredOptions.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
{filteredOptions.slice(0, 60).map(g => (
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
{g}
<div className="genre-filter-popover__list">
{filteredGenres.length === 0 ? (
<div className="genre-filter-popover__empty">
{t('common.filterNoGenres')}
</div>
))}
) : (
filteredGenres.map(g => {
const isSel = selectedSet.has(g);
return (
<div
key={g}
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
onClick={() => toggle(g)}
role="option"
aria-selected={isSel}
>
<span className="genre-filter-popover__check">
{isSel && <Check size={12} strokeWidth={3} />}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{g}
</span>
</div>
);
})
)}
</div>
)}
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
</div>
)}
</div>
{selected.length > 0 && (
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
<X size={13} />
{t('common.filterClear')}
</button>
{count > 0 && (
<div className="genre-filter-popover__footer">
<button
className="btn btn-ghost"
onClick={clear}
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
>
<X size={13} />
{t('common.filterClear')}
</button>
</div>
)}
</div>,
document.body,
)}
</div>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useConfirmModalStore } from '../store/confirmModalStore';
import ConfirmModal from './ConfirmModal';
/**
* App-level singleton renderer for the global confirm modal. Mount once
* in App.tsx; any code path can then call
* `useConfirmModalStore.getState().request(...)` and await the user's decision.
*/
export default function GlobalConfirmModal() {
const { isOpen, title, message, confirmLabel, cancelLabel, danger, confirm, cancel } =
useConfirmModalStore();
return (
<ConfirmModal
open={isOpen}
title={title}
message={message}
confirmLabel={confirmLabel}
cancelLabel={cancelLabel}
danger={danger}
onConfirm={confirm}
onCancel={cancelLabel ? cancel : undefined}
/>
);
}
+11 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
@@ -65,6 +66,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -87,18 +89,23 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
mixMinRatingArtist,
]);
// Start / restart auto-advance timer
// Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return;
timerRef.current = null;
if (len <= 1 || windowHidden) return;
timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, []);
}, [windowHidden]);
useEffect(() => {
startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]);
const goTo = useCallback((idx: number) => {
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, X, Inbox } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import {
approveOrbitSuggestion,
declineOrbitSuggestion,
suggestionKey,
} from '../utils/orbit';
import {
getSong,
buildCoverArtUrl,
coverArtCacheKey,
type SubsonicSong,
} from '../api/subsonic';
import CachedImage from './CachedImage';
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
/**
* Host-only approval strip. Renders directly below the OrbitQueueHead
* when `autoApprove === false` and at least one guest suggestion is
* waiting. Shows each pending track with Approve / Decline controls.
*
* Only rendered by the host-side render path (QueuePanel); guests never
* see this section they watch their own pending list.
*/
export default function HostApprovalQueue() {
const { t } = useTranslation();
const role = useOrbitStore(s => s.role);
const state = useOrbitStore(s => s.state);
const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys);
const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys);
const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS;
const autoApproveOff = settings.autoApprove === false;
// Pending = everything in the session's suggestion history that isn't
// host-authored, isn't already merged, and hasn't been declined.
const pendingItems = useMemo(() => {
if (!state) return [];
const mergedSet = new Set(mergedKeys);
const declinedSet = new Set(declinedKeys);
return state.queue.filter(q =>
q.addedBy !== state.host
&& !mergedSet.has(suggestionKey(q))
&& !declinedSet.has(suggestionKey(q))
);
}, [state, mergedKeys, declinedKeys]);
// Track-metadata cache (title/artist/cover) for the pending items.
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
const wantedKey = useMemo(
() => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'),
[pendingItems],
);
useEffect(() => {
const wanted = wantedKey ? wantedKey.split('|') : [];
const missing = wanted.filter(id => id && !songs[id]);
if (missing.length === 0) return;
let cancelled = false;
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
.then(results => {
if (cancelled) return;
setSongs(prev => {
const next = { ...prev };
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
return next;
});
});
return () => { cancelled = true; };
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) {
return null;
}
return (
<div className="host-approval">
<div className="host-approval__head">
<Inbox size={12} />
<span>{t('orbit.approvalTitle')}</span>
<span className="host-approval__count">{pendingItems.length}</span>
</div>
<div className="host-approval__list">
{pendingItems.map(q => {
const song = songs[q.trackId];
const key = suggestionKey(q);
return (
<div key={key} className="host-approval__item">
{song?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 48)}
cacheKey={coverArtCacheKey(song.coverArt, 48)}
alt=""
className="host-approval__cover"
/>
) : (
<div className="host-approval__cover host-approval__cover--ph" />
)}
<div className="host-approval__info">
<div className="host-approval__title">{song?.title ?? '…'}</div>
<div className="host-approval__artist">{song?.artist ?? ''}</div>
<div className="host-approval__submitter">
{t('orbit.approvalFrom', { user: q.addedBy })}
</div>
</div>
<div className="host-approval__actions">
<button
type="button"
className="host-approval__btn host-approval__btn--approve"
onClick={() => { void approveOrbitSuggestion(q); }}
data-tooltip={t('orbit.approvalAccept')}
aria-label={t('orbit.approvalAccept')}
>
<Check size={13} />
</button>
<button
type="button"
className="host-approval__btn host-approval__btn--decline"
onClick={() => { declineOrbitSuggestion(q); }}
data-tooltip={t('orbit.approvalDecline')}
aria-label={t('orbit.approvalDecline')}
>
<X size={13} />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export default function LastfmIndicator() {
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
>
+50 -13
View File
@@ -1,10 +1,12 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
import { showToast } from '../utils/toast';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
@@ -22,7 +24,11 @@ export default function LiveSearch() {
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate();
const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id);
const ctxType = usePlayerStore(state => state.contextMenu.type);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
@@ -44,14 +50,19 @@ export default function LiveSearch() {
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
// Close on click outside
// Close on click outside — but stay open while a song context menu is up.
// The CM renders a fullscreen transparent backdrop (z-index 998) above the
// dropdown, so any mousedown — including a second right-click on another
// row — would otherwise hit the backdrop and trip this handler, yanking the
// dropdown closed mid-interaction.
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ctxIsOpen) return;
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
}, [ctxIsOpen]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
@@ -60,7 +71,9 @@ export default function LiveSearch() {
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
playTrack(songToTrack(s));
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery('');
}}))),
] : [];
@@ -144,9 +157,14 @@ export default function LiveSearch() {
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Users size={14} /></div>
<span>{a.name}</span>
@@ -161,12 +179,22 @@ export default function LiveSearch() {
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
<CachedImage
className="search-result-thumb"
src={buildCoverArtUrl(a.coverArt, 40)}
cacheKey={coverArtCacheKey(a.coverArt, 40)}
alt=""
/>
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
@@ -185,12 +213,21 @@ export default function LiveSearch() {
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => {
playTrack(songToTrack(s));
setOpen(false); setQuery('');
}}
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery('');
}}
onContextMenu={(e) => {
e.preventDefault();
// Keep the dropdown open — context menu portal renders above it,
// and closing here would yank the list out from under the user.
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div>
<div>
+8 -13
View File
@@ -6,7 +6,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
import { SpringScroller, targetForFraction } from '../utils/springScroll';
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
interface Props {
currentTrack: Track | null;
@@ -33,37 +33,32 @@ export default function LyricsPane({ currentTrack }: Props) {
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const containerRef = useRef<HTMLDivElement | null>(null);
const springRef = useRef<SpringScroller | null>(null);
const scrollerRef = useRef<EaseScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevActive = useRef({ line: -1, word: -1 });
const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Attach/detach SpringScroller when the pane mounts.
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
springRef.current?.stop();
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null;
scrollerRef.current?.stop();
scrollerRef.current = el ? new EaseScroller(el) : null;
}, []);
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
const handleUserScroll = useCallback(() => {
springRef.current?.stop();
scrollerRef.current?.stop();
isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
}, []);
// Scroll active line into view.
// Apple style: spring-animate to ~35% from top.
// Classic style: native scrollIntoView center.
const scrollToLine = useCallback((el: HTMLDivElement) => {
if (isUserScroll.current) return;
const container = containerRef.current;
if (!container) return;
if (sidebarLyricsStyle === 'apple' && springRef.current) {
springRef.current.scrollTo(targetForFraction(container, el, 0.35));
if (sidebarLyricsStyle === 'apple' && scrollerRef.current) {
scrollerRef.current.scrollTo(targetForFraction(container, el, 0.35));
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
@@ -74,7 +69,7 @@ export default function LyricsPane({ currentTrack }: Props) {
lineRefs.current = [];
wordRefs.current = [];
prevActive.current = { line: -1, word: -1 };
springRef.current?.jump(0);
scrollerRef.current?.jump(0);
}, [currentTrack?.id]);
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { emit } from '@tauri-apps/api/event';
import { useTranslation } from 'react-i18next';
import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react';
import { star, unstar } from '../api/subsonic';
import type { MiniTrackInfo } from '../utils/miniPlayerBridge';
interface Props {
x: number;
y: number;
track: MiniTrackInfo;
index: number;
onClose: () => void;
}
/**
* Slim queue-item context menu for the mini player. The mini lives in its
* own webview, so all queue mutations forward to the main window via Tauri
* events; only the favorite call hits Subsonic directly because it has no
* cross-window state to keep in sync (next mini:sync from main reflects the
* new starred flag).
*/
export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const [starred, setStarred] = useState(!!track.starred);
const [pos, setPos] = useState({ left: x, top: y });
// Clamp the menu inside the mini window's viewport (it pops near the
// cursor and would otherwise overflow at the right/bottom edges of the
// small window).
useEffect(() => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const left = Math.min(x, Math.max(4, vw - r.width - 4));
const top = Math.min(y, Math.max(4, vh - r.height - 4));
setPos({ left, top });
}, [x, y]);
// Dismiss on outside click + Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
const run = (fn: () => void | Promise<void>) => {
Promise.resolve(fn()).finally(onClose);
};
const toggleStar = async () => {
const next = !starred;
setStarred(next);
try {
if (next) await star(track.id, 'song');
else await unstar(track.id, 'song');
} catch {
setStarred(!next);
}
};
return createPortal(
<div
ref={ref}
className="context-menu mini-context-menu"
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div
className="context-menu-item"
style={{ color: 'var(--danger)' }}
onClick={() => run(() => emit('mini:remove', { index }))}
>
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
{track.albumId && (
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
{track.artistId && (
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
)}
<div className="context-menu-item" onClick={() => run(toggleStar)}>
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
<div className="context-menu-divider" />
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</div>,
document.body,
);
}
+756
View File
@@ -0,0 +1,756 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, MoveRight } from 'lucide-react';
import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import OverlayScrollArea from './OverlayScrollArea';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
const COLLAPSED_SIZE = { w: 340, h: 260 };
const EXPANDED_SIZE = { w: 340, h: 500 };
// Minimum window dimensions per state. When the queue is open the floor must
// keep at least two queue rows visible; a stricter min would let the user
// collapse the queue area to nothing while it's still toggled on.
const COLLAPSED_MIN = { w: 320, h: 240 };
const EXPANDED_MIN = { w: 320, h: 340 };
// Persist the expanded-window height so reopening the queue restores the
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
function readStoredExpandedHeight(): number {
try {
const raw = localStorage.getItem(EXPANDED_H_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n;
}
} catch {}
return EXPANDED_SIZE.h;
}
// Persist whether the queue panel was open so the next launch restores
// the same state. Same scope as the height: localStorage of the mini
// webview (shared across mini sessions, separate from the main store).
const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
function readQueueOpen(): boolean {
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
}
function toMini(t: any): MiniTrackInfo {
return {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
year: t.year,
};
}
/**
* Hydrate from the persisted playerStore so initial paint shows real content
* instead of "—" while we wait for the mini:sync event from the main window.
* The persisted state covers the cold-start window (webview boot + bundle).
*/
function initialSnapshot(): MiniSyncPayload {
try {
const s = usePlayerStore.getState();
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
isPlaying: s.isPlaying,
volume: s.volume ?? 1,
gaplessEnabled: false,
crossfadeEnabled: false,
infiniteQueueEnabled: false,
isMobile: false,
};
} catch {
return {
track: null, queue: [], queueIndex: 0, isPlaying: false,
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
infiniteQueueEnabled: false, isMobile: false,
};
}
}
interface ProgressPayload {
current_time: number;
duration: number;
}
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) seconds = 0;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function MiniPlayer() {
const { t } = useTranslation();
const [state, setState] = useState<MiniSyncPayload>(() => initialSnapshot());
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(() => {
const initial = initialSnapshot();
return initial.track?.duration ?? 0;
});
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
const volumeBtnRef = useRef<HTMLButtonElement>(null);
const volumePopRef = useRef<HTMLDivElement>(null);
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
const hiddenRef = useRef(false);
const isHidden = useWindowVisibility();
useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]);
// ── PsyDnD reorder ──
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
// on the queue computes a drop indicator, psy-drop emits mini:reorder back
// to main where the source-of-truth store lives.
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
const psyDragFromIdxRef = useRef<number | null>(null);
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
const isReorderDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; }
})();
useEffect(() => {
if (!isPsyDragging) {
dropTargetRef.current = null;
setDropTarget(null);
}
}, [isPsyDragging]);
// ── Context menu state ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
// Announce to main window that we're mounted; it replies with a snapshot.
// Also re-announce on window focus: on Windows the mini is pre-created at
// app startup so the mount-time emit can race past main's bridge before
// it has attached its listener. Re-emitting on focus means every actual
// open of the mini (user clicks the player-bar icon) triggers a fresh
// sync regardless of startup ordering.
useEffect(() => {
emit('mini:ready', {}).catch(() => {});
const onFocus = () => { emit('mini:ready', {}).catch(() => {}); };
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
// Mini is a separate WebKitGTK webview: Rust applies smooth-wheel per window.
// Re-send after auth persist hydrates so preloaded/hidden mini matches Settings.
useEffect(() => {
if (!IS_LINUX) return;
const apply = () => {
invoke('set_linux_webkit_smooth_scrolling', {
enabled: useAuthStore.getState().linuxWebkitKineticScroll,
}).catch(() => {});
};
apply();
return useAuthStore.persist.onFinishHydration(() => {
apply();
});
}, []);
// Restore the expanded window size on initial mount when the queue was
// open at the previous app close. Rust always builds the window at the
// collapsed size; without this we'd render queueOpen=true into a 180 px
// window. Brief jump from collapsed to expanded is unavoidable since
// localStorage only lives in JS.
useEffect(() => {
if (!queueOpen) return;
invoke('resize_mini_player', {
width: EXPANDED_SIZE.w,
height: readStoredExpandedHeight(),
minWidth: EXPANDED_MIN.w,
minHeight: EXPANDED_MIN.h,
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-apply pin state on mount and whenever the window regains focus.
// After a Hide → Show cycle (which is what `open_mini_player` does on
// re-toggle) the WM often drops the always-on-top constraint silently;
// re-asserting it here means the user no longer has to click the pin
// button twice to make it stick.
useEffect(() => {
invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {});
const reapply = () => {
if (alwaysOnTop) {
invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {});
}
};
window.addEventListener('focus', reapply);
return () => window.removeEventListener('focus', reapply);
}, [alwaysOnTop]);
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
// Also honour the user-configured 'open-mini-player' shortcut so the
// same chord that opens the mini from main also closes it from here.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tgt = e.target as HTMLElement | null;
const tag = tgt?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return;
const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player'];
if (matchInAppBinding(e, openMiniBinding)) {
e.preventDefault();
invoke('open_mini_player').catch(() => {});
return;
}
if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyZ' || e.key?.toLowerCase() === 'z')) {
e.preventDefault();
if (e.shiftKey) {
emit('mini:redo-queue', {}).catch(() => {});
} else {
emit('mini:undo-queue', {}).catch(() => {});
}
return;
}
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
emit('mini:control', 'toggle').catch(() => {});
} else if (e.key === 'ArrowRight') {
emit('mini:control', 'next').catch(() => {});
} else if (e.key === 'ArrowLeft') {
emit('mini:control', 'prev').catch(() => {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Subscribe to state + progress from the main window / Rust.
useEffect(() => {
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
setState(e.payload);
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
});
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
if (hiddenRef.current || window.__psyHidden) return;
setCurrentTime(e.payload.current_time);
if (e.payload.duration > 0) setDuration(e.payload.duration);
});
const unEnded = listen('audio:ended', () => setCurrentTime(0));
return () => {
unSync.then(fn => fn()).catch(() => {});
unProgress.then(fn => fn()).catch(() => {});
unEnded.then(fn => fn()).catch(() => {});
if (ticker.current) window.clearInterval(ticker.current);
};
}, []);
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
const handleVolumeChange = (v: number) => {
const clamped = Math.max(0, Math.min(1, v));
setVolumeState(clamped);
emit('mini:set-volume', { value: clamped }).catch(() => {});
};
const toggleMute = () => {
handleVolumeChange(volume === 0 ? 1 : 0);
};
// Position the portaled volume popover relative to its trigger button.
// Auto-flip above when there is not enough room below (mini window is short).
const updateVolumePopStyle = () => {
if (!volumeBtnRef.current) return;
const rect = volumeBtnRef.current.getBoundingClientRect();
const MARGIN = 6;
const POP_W = 40;
const POP_H = 150;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < POP_H && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left + rect.width / 2 - POP_W / 2, 6),
window.innerWidth - POP_W - 6,
);
setVolumePopStyle({
position: 'fixed',
left,
width: POP_W,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!volumeOpen) return;
updateVolumePopStyle();
}, [volumeOpen]);
useEffect(() => {
if (!volumeOpen) return;
const onReposition = () => updateVolumePopStyle();
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
return () => {
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
};
}, [volumeOpen]);
// Close the volume popover on outside click / Escape. The popover is now
// portaled, so check both the trigger button and the popover ref.
useEffect(() => {
if (!volumeOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
!volumeBtnRef.current?.contains(target) &&
!volumePopRef.current?.contains(target)
) setVolumeOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setVolumeOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [volumeOpen]);
const toggleOnTop = async () => {
const next = !alwaysOnTop;
setAlwaysOnTop(next);
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch {}
};
const showMain = () => invoke('show_main_window').catch(() => {});
const toggleQueue = async () => {
const next = !queueOpen;
// Capture the current expanded height before collapsing so the next
// open restores it. Read window.innerHeight directly — it matches the
// logical inner size that resize_mini_player set previously.
if (!next) {
const h = Math.round(window.innerHeight);
if (h >= EXPANDED_MIN.h) {
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
}
}
setQueueOpen(next);
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
try {
await invoke('resize_mini_player', {
width: targetW,
height: targetH,
minWidth: min.w,
minHeight: min.h,
});
} catch {}
};
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
// Listen for psy-drop on the queue. Only handles `queue_reorder` payloads
// since the mini player has no external drag sources. `queueOpen` must be
// in deps because the wrap (and thus queueScrollRef.current) only mounts
// when the queue is expanded — without it the ref is null on first run
// and the listener never attaches.
useEffect(() => {
if (!queueOpen) return;
const el = queueScrollRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: any = null;
try { parsed = JSON.parse(detail.data); } catch { return; }
const tgt = dropTargetRef.current;
dropTargetRef.current = null;
setDropTarget(null);
if (parsed.type !== 'queue_reorder') return;
const fromIdx = parsed.index as number;
psyDragFromIdxRef.current = null;
const queueLen = usePlayerStore.getState().queue.length || state.queue.length;
const insertIdx = tgt
? (tgt.before ? tgt.idx : tgt.idx + 1)
: queueLen;
if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return;
// Adjust target index if removing the source first shifts later items.
const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
if (fromIdx === adjusted) return;
emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {});
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [queueOpen, state.queue.length]);
// Auto-scroll the current track into view when the queue expands.
useEffect(() => {
if (!queueOpen) return;
const el = queueScrollRef.current?.querySelector<HTMLElement>('.mini-queue__item--current');
el?.scrollIntoView({ block: 'nearest' });
requestAnimationFrame(() => {
queueScrollRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
});
}, [queueOpen, state.queueIndex]);
const { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="mini-player-shell">
<div
className={`mini-player__titlebar${!IS_LINUX ? ' mini-player__titlebar--mac' : ''}`}
{...(!IS_LINUX ? {} : { 'data-tauri-drag-region': true })}
>
{IS_LINUX ? (
<span className="mini-player__titlebar-title" data-tauri-drag-region>
{track?.title ?? 'Psysonic Mini'}
</span>
) : (
// macOS/Windows already render a native titlebar with the window
// title + close button; we just need a flexible spacer so the
// action buttons sit right.
<span className="mini-player__titlebar-spacer" />
)}
<button
type="button"
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
onClick={toggleOnTop}
data-tauri-drag-region="false"
data-tooltip={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
aria-label={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
>
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
</button>
<button
type="button"
className="mini-player__titlebar-btn"
onClick={showMain}
data-tauri-drag-region="false"
data-tooltip={t('miniPlayer.openMainWindow')}
aria-label={t('miniPlayer.openMainWindow')}
>
<Maximize2 size={13} />
</button>
{/* macOS + Windows already provide Close via the native titlebar
skip the duplicate so the in-app titlebar stays minimal. */}
{IS_LINUX && (
<button
type="button"
className="mini-player__titlebar-btn mini-player__titlebar-btn--close"
onClick={closeMini}
data-tauri-drag-region="false"
data-tooltip={t('miniPlayer.close')}
aria-label={t('miniPlayer.close')}
>
<X size={13} />
</button>
)}
</div>
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<div className="mini-player__meta">
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
alt={track.album}
/>
) : (
<div className="mini-player__art-fallback" />
)}
</div>
<div className="mini-player__meta-text" data-tauri-drag-region="false">
<div className="mini-player__title" title={track?.title}>
{track?.title ?? '—'}
</div>
{track?.artist && (
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
)}
{track?.album && (
<div className="mini-player__album" title={track.album}>{track.album}</div>
)}
{track?.year && (
<div className="mini-player__year">{track.year}</div>
)}
</div>
</div>
<div className="mini-player__toolbar" data-tauri-drag-region="false">
<div className="mini-player__volume-wrap">
<button
ref={volumeBtnRef}
type="button"
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => setVolumeOpen(v => !v)}
onContextMenu={(e) => { e.preventDefault(); toggleMute(); }}
data-tauri-drag-region="false"
data-tooltip={volume === 0 ? t('player.volume') : `${t('player.volume')} ${Math.round(volume * 100)}%`}
aria-label={t('player.volume')}
>
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button>
{volumeOpen && createPortal(
<div
ref={volumePopRef}
className="mini-player__volume-popover"
style={volumePopStyle}
data-tauri-drag-region="false"
>
<span className="mini-player__volume-pct">{Math.round(volume * 100)}%</span>
<div
className="mini-player__volume-bar"
role="slider"
aria-label={t('player.volume')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(volume * 100)}
onMouseDown={(e) => {
const target = e.currentTarget;
const setFromY = (clientY: number) => {
const rect = target.getBoundingClientRect();
const ratio = 1 - (clientY - rect.top) / rect.height;
handleVolumeChange(ratio);
};
setFromY(e.clientY);
const onMove = (me: MouseEvent) => setFromY(me.clientY);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
onWheel={(e) => {
e.preventDefault();
handleVolumeChange(volume + (e.deltaY > 0 ? -0.05 : 0.05));
}}
>
<div
className="mini-player__volume-bar-fill"
style={{ height: `${Math.round(volume * 100)}%` }}
/>
</div>
</div>,
document.body,
)}
</div>
<button
type="button"
className="mini-player__tool"
onClick={() => emit('mini:shuffle').catch(() => {})}
disabled={state.queue.length < 2}
data-tauri-drag-region="false"
data-tooltip={t('queue.shuffle')}
aria-label={t('queue.shuffle')}
>
<Shuffle size={13} />
</button>
<span className="mini-player__toolbar-sep" aria-hidden />
<button
type="button"
className={`mini-player__tool${state.gaplessEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-gapless', { value: !state.gaplessEnabled }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<MoveRight size={13} />
</button>
<button
type="button"
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
<button
type="button"
className={`mini-player__tool${state.infiniteQueueEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-infinite-queue', { value: !state.infiniteQueueEnabled }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<InfinityIcon size={13} />
</button>
<span className="mini-player__toolbar-sep" aria-hidden />
<button
type="button"
className={`mini-player__tool${queueOpen ? ' mini-player__tool--active' : ''}`}
onClick={toggleQueue}
data-tauri-drag-region="false"
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
>
<ListMusic size={13} />
</button>
</div>
{queueOpen && (
<OverlayScrollArea
viewportRef={queueScrollRef}
className="mini-queue-wrap"
viewportClassName="mini-queue"
measureDeps={[queueOpen, state.queue.length]}
railInset="mini"
viewportScrollBehaviorAuto={isReorderDrag}
onMouseMove={(e) => {
if (!isReorderDrag || !queueScrollRef.current) return;
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[data-mq-idx]');
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
const idx = parseInt(items[i].dataset.mqIdx!, 10);
const t = { idx, before };
dropTargetRef.current = t;
setDropTarget(t);
return;
}
}
dropTargetRef.current = null;
setDropTarget(null);
}}
>
{state.queue.length === 0 ? (
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : (
state.queue.map((t, i) => {
let dragStyle: React.CSSProperties = {};
if (isReorderDrag && psyDragFromIdxRef.current === i) {
dragStyle = { opacity: 0.4 };
} else if (isReorderDrag && dropTarget?.idx === i) {
dragStyle = dropTarget.before
? { boxShadow: 'inset 0 2px 0 var(--accent)' }
: { boxShadow: 'inset 0 -2px 0 var(--accent)' };
}
return (
<button
key={`${t.id}-${i}`}
data-mq-idx={i}
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
onClick={() => jumpTo(i)}
onContextMenu={(e) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
// Don't start drag while a click would also be valid —
// the threshold check below upgrades to a drag once
// the pointer leaves the deadband.
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = i;
startDrag(
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
me.clientX,
me.clientY,
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={dragStyle}
>
<span className="mini-queue__num">{i + 1}</span>
<div className="mini-queue__meta">
<div className="mini-queue__title">{t.title}</div>
<div className="mini-queue__artist">{t.artist}</div>
</div>
</button>
);
})
)}
</OverlayScrollArea>
)}
<div className="mini-player__bottom" data-tauri-drag-region="false">
<div className="mini-player__controls">
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
<SkipBack size={16} />
</button>
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
<SkipForward size={16} />
</button>
</div>
<div className="mini-player__progress">
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
<div className="mini-player__progress-track">
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="mini-player__progress-time">{fmt(duration)}</div>
</div>
</div>
{ctxMenu && (
<MiniContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
track={ctxMenu.track}
index={ctxMenu.index}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { createPortal } from 'react-dom';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Settings, HardDriveDownload } from 'lucide-react';
import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const items = sidebarItems
.filter(cfg => {
if (!cfg?.visible) return false;
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return false;
if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
return createPortal(
<>
<div className="mobile-more-backdrop" onClick={onClose} />
<div className="mobile-more-sheet">
<div className="mobile-more-handle" />
<div className="mobile-more-grid">
{items.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><item.icon size={24} /></span>
<span className="mobile-more-label">{t(item.labelKey)}</span>
</NavLink>
))}
{hasOfflineContent && (
<NavLink
to="/offline"
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><HardDriveDownload size={24} /></span>
<span className="mobile-more-label">{t('sidebar.offlineLibrary')}</span>
</NavLink>
)}
<NavLink
to="/settings"
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><Settings size={24} /></span>
<span className="mobile-more-label">{t('sidebar.settings')}</span>
</NavLink>
</div>
</div>
</>,
document.body
);
}
+61 -11
View File
@@ -4,11 +4,16 @@ import { useTranslation } from 'react-i18next';
import {
ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
// ── Color extraction ──────────────────────────────────────────────────────────
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
@@ -162,6 +167,10 @@ export default function MobilePlayerView() {
const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime);
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const seek = usePlayerStore(s => s.seek);
@@ -204,14 +213,21 @@ export default function MobilePlayerView() {
// Scrubber touch/mouse drag
const scrubberRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const pendingSeekRef = useRef<number | null>(null);
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
const setPreviewSeek = useCallback((pct: number) => {
pendingSeekRef.current = pct;
setPreviewProgress(pct);
}, []);
const seekFromX = useCallback((clientX: number) => {
const el = scrubberRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
seek(pct);
}, [seek]);
setPreviewSeek(pct);
}, [setPreviewSeek]);
const onScrubStart = useCallback((clientX: number) => {
isDragging.current = true;
@@ -224,7 +240,14 @@ export default function MobilePlayerView() {
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
seekFromX(clientX);
};
const onEnd = () => { isDragging.current = false; };
const onEnd = () => {
if (!isDragging.current) return;
isDragging.current = false;
const pending = pendingSeekRef.current;
pendingSeekRef.current = null;
setPreviewProgress(null);
if (pending !== null) seek(pending);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
@@ -238,6 +261,11 @@ export default function MobilePlayerView() {
};
}, [seekFromX]);
useEffect(() => {
pendingSeekRef.current = null;
setPreviewProgress(null);
}, [currentTrack?.id]);
// Drawers
const [showQueue, setShowQueue] = useState(false);
const [showLyrics, setShowLyrics] = useState(false);
@@ -264,6 +292,11 @@ export default function MobilePlayerView() {
const bgStyle: CSSProperties = {
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
};
const effectiveProgress = previewProgress ?? progress;
const effectiveTime =
previewProgress !== null && duration > 0
? previewProgress * duration
: currentTime;
return (
<div className="mp-view" style={bgStyle}>
@@ -328,17 +361,17 @@ export default function MobilePlayerView() {
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
>
<div className="mp-scrubber-bg" />
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
<div className="mp-scrubber-fill" style={{ width: `${effectiveProgress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${effectiveProgress * 100}%` }} />
</div>
<div className="mp-scrubber-times">
<span>{formatTime(currentTime)}</span>
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
<span>{formatTime(effectiveTime)}</span>
<span>-{formatTime(Math.max(0, duration - effectiveTime))}</span>
</div>
</div>
{/* Transport Controls */}
<div className="mp-controls">
<div className="mp-controls" ref={transportAnchorRef}>
<button
className="mp-ctrl-btn mp-ctrl-sm"
onClick={() => shuffleQueue()}
@@ -349,9 +382,24 @@ export default function MobilePlayerView() {
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
<button
className="mp-ctrl-btn mp-ctrl-play"
type="button"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--mobile`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={13} strokeWidth={2.5} />
: <Sunrise size={13} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--mobile">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
</span>
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={28} />
</button>
@@ -382,6 +430,8 @@ export default function MobilePlayerView() {
{/* Lyrics Drawer */}
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
</div>
);
}
+21 -7
View File
@@ -2,10 +2,12 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
import { showToast } from '../utils/toast';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
@@ -28,7 +30,7 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
@@ -63,9 +65,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
const goCategory = (path: string) => { navigate(path); onClose(); };
const playSong = (song: SearchResults['songs'][number]) => {
const enqueueSong = (song: SearchResults['songs'][number]) => {
commit(query);
playTrack(songToTrack(song));
const track = songToTrack(song);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
onClose();
};
const useRecent = (term: string) => {
@@ -203,7 +207,12 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
<CachedImage
className="mobile-search-thumb"
src={buildCoverArtUrl(a.coverArt, 80)}
cacheKey={coverArtCacheKey(a.coverArt, 80)}
alt=""
/>
) : (
<div className="mobile-search-avatar">
<Disc3 size={20} />
@@ -223,9 +232,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
{s.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
<CachedImage
className="mobile-search-thumb"
src={buildCoverArtUrl(s.coverArt, 80)}
cacheKey={coverArtCacheKey(s.coverArt, 80)}
alt=""
/>
) : (
<div className="mobile-search-avatar">
<Music size={20} />
+311
View File
@@ -0,0 +1,311 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { getArtistInfo, getSong, type SubsonicArtistInfo, type SubsonicSong } from '../api/subsonic';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
const TOUR_LIMIT = 5;
const BIO_CLAMP_LINES = 4;
/**
* Cross-mount caches keyed by stable IDs so jumping between tracks of the same
* artist / album doesn't refire the network call. Cleared on app restart.
*/
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
const songDetailCache = new Map<string, SubsonicSong | null>();
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const month = d.toLocaleString(undefined, { month: 'short' });
const day = String(d.getDate());
const weekday = d.toLocaleString(undefined, { weekday: 'short' });
const time = d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' });
return { month, day, weekday, time };
}
interface ContributorRow {
role: string;
names: string[];
}
/**
* Build credits from OpenSubsonic `contributors[]` only. The legacy
* artist/albumArtist/composer fallback is intentionally dropped it
* just repeats what's already shown above the tab.
*/
function buildContributorRows(
song: SubsonicSong | null | undefined,
mainArtistName: string,
): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<string, Set<string>>();
for (const c of song.contributors) {
const role = c.role?.trim();
const name = c.artist?.name?.trim();
if (!role || !name) continue;
const label = c.subRole ? `${role}${c.subRole}` : role;
let bucket = rows.get(label);
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
bucket.add(name);
}
// Drop a row that only restates the main artist under the "artist" role.
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
const isMainArtistOnly =
role.toLowerCase().startsWith('artist') &&
list.length === 1 &&
list[0].toLowerCase() === mainLower;
if (isMainArtistOnly) continue;
out.push({ role, names: list });
}
return out;
}
export default function NowPlayingInfo() {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const artistName = currentTrack?.artist || '';
const artistId = currentTrack?.artistId || '';
const songId = currentTrack?.id || '';
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
artistId ? artistInfoCache.get(artistId) ?? null : null,
);
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
songId ? songDetailCache.get(songId) ?? null : null,
);
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
const [tourLoading, setTourLoading] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
const [bioOverflows, setBioOverflows] = useState(false);
const [showAllTours, setShowAllTours] = useState(false);
const bioRef = useRef<HTMLParagraphElement | null>(null);
// Reset per-track UI state when the track changes
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
// Artist bio + image
useEffect(() => {
if (!artistId) { setArtistInfo(null); return; }
const cached = artistInfoCache.get(artistId);
if (cached !== undefined) { setArtistInfo(cached); return; }
let cancelled = false;
getArtistInfo(artistId)
.then(info => { if (!cancelled) { artistInfoCache.set(artistId, info ?? null); setArtistInfo(info ?? null); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } });
return () => { cancelled = true; };
}, [artistId]);
// Song detail (for OpenSubsonic contributors[])
useEffect(() => {
if (!songId) { setSongDetail(null); return; }
const cached = songDetailCache.get(songId);
if (cached !== undefined) { setSongDetail(cached); return; }
let cancelled = false;
getSong(songId)
.then(song => { if (!cancelled) { songDetailCache.set(songId, song ?? null); setSongDetail(song ?? null); } })
.catch(() => { if (!cancelled) { songDetailCache.set(songId, null); setSongDetail(null); } });
return () => { cancelled = true; };
}, [songId]);
// Bandsintown — only when opt-in toggle is on
useEffect(() => {
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
let cancelled = false;
setTourLoading(true);
fetchBandsintownEvents(artistName)
.then(events => { if (!cancelled) setTourEvents(events); })
.finally(() => { if (!cancelled) setTourLoading(false); });
return () => { cancelled = true; };
}, [enableBandsintown, artistName]);
// Detect whether the (clamped) bio actually overflows so we hide the toggle
// when it would do nothing.
const bio = artistInfo?.biography?.trim() || '';
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
useLayoutEffect(() => {
const el = bioRef.current;
if (!el) { setBioOverflows(false); return; }
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
}, [bioClean]);
const contributorRows = useMemo(
() => buildContributorRows(songDetail, artistName),
[songDetail, artistName],
);
if (!currentTrack) {
return (
<div className="np-info-empty">
{t('nowPlayingInfo.empty', 'Play something to see info')}
</div>
);
}
const heroImage = artistInfo?.largeImageUrl || artistInfo?.mediumImageUrl || '';
const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
return (
<div className="np-info">
{/* Artist card */}
<section className="np-info-section np-info-artist">
{heroImage && heroCacheKey && (
<div className="np-info-artist-image-wrap">
<CachedImage
src={heroImage}
cacheKey={heroCacheKey}
alt={artistName}
className="np-info-artist-image"
/>
</div>
)}
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{bioClean && (
<>
<p
ref={bioRef}
className={`np-info-artist-bio${bioExpanded ? '' : ' is-clamped'}`}
style={bioExpanded ? undefined : { WebkitLineClamp: BIO_CLAMP_LINES }}
>
{bioClean}
</p>
{(bioOverflows || bioExpanded) && (
<button
type="button"
className="np-info-link-btn"
onClick={() => setBioExpanded(v => !v)}
>
{bioExpanded
? t('nowPlayingInfo.bioReadLess', 'Show less')
: t('nowPlayingInfo.bioReadMore', 'Read more')}
</button>
)}
</>
)}
</div>
</section>
{/* Song info / contributors — only when OpenSubsonic provided real credits */}
{contributorRows.length > 0 && (
<section className="np-info-section">
<div className="np-info-section-title">{t('nowPlayingInfo.songInfo', 'Song info')}</div>
<ul className="np-info-credits">
{contributorRows.map(row => (
<li key={row.role} className="np-info-credit-row">
<span className="np-info-credit-names">{row.names.join(', ')}</span>
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
</li>
))}
</ul>
</section>
)}
{/* Tour: prompt to opt-in when off, list when on */}
{!enableBandsintown ? (
<section className="np-info-section">
<div className="np-info-bandsintown-prompt">
<div className="np-info-bandsintown-prompt-title">
<span>{t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')}</span>
<span
className="np-info-bandsintown-prompt-info"
data-tooltip={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
data-tooltip-pos="bottom"
data-tooltip-wrap="true"
aria-label={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
tabIndex={0}
>
<Info size={13} />
</span>
</div>
<div className="np-info-bandsintown-prompt-desc">
{t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')}
</div>
<button
type="button"
className="np-info-bandsintown-prompt-btn"
onClick={() => setEnableBandsintown(true)}
>
{t('nowPlayingInfo.enableBandsintownAction', 'Enable')}
</button>
</div>
</section>
) : (
<section className="np-info-section">
<div className="np-info-section-title">{t('nowPlayingInfo.onTour', 'On tour')}</div>
{tourLoading && tourEvents.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.tourLoading', 'Loading…')}</div>
)}
{!tourLoading && tourEvents.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}</div>
)}
{visibleTours.length > 0 && (
<ul className="np-info-tour">
{visibleTours.map((ev, idx) => {
const parts = isoToParts(ev.datetime);
const place = [ev.venueCity, ev.venueRegion, ev.venueCountry]
.filter(Boolean).join(', ');
return (
<li
key={`${ev.datetime}-${ev.venueName}-${idx}`}
className="np-info-tour-item"
onClick={() => ev.url && shellOpen(ev.url).catch(() => {})}
role={ev.url ? 'button' : undefined}
tabIndex={ev.url ? 0 : undefined}
>
{parts && (
<div className="np-info-tour-date">
<div className="np-info-tour-date-month">{parts.month}</div>
<div className="np-info-tour-date-day">{parts.day}</div>
</div>
)}
<div className="np-info-tour-meta">
<div className="np-info-tour-venue">{ev.venueName || place}</div>
<div className="np-info-tour-place">
{parts && (
<span className="np-info-tour-when">{parts.weekday}, {parts.time}</span>
)}
{parts && place && <span className="np-info-tour-sep"> </span>}
<span>{place}</span>
</div>
</div>
</li>
);
})}
</ul>
)}
{(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && (
<button
type="button"
className="np-info-tour-more"
onClick={() => setShowAllTours(v => !v)}
>
{showAllTours
? t('nowPlayingInfo.showLessTours', 'Show less')
: t('nowPlayingInfo.showMoreTours', { defaultValue: 'Show {{count}} more', count: hiddenTourCount })}
</button>
)}
<div className="np-info-tour-credit">
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
</div>
</section>
)}
</div>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ export default function OfflineBanner({ onRetry, isChecking, showSettingsLink, s
{showSettingsLink && (
<button
className="offline-banner-retry"
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
onClick={() => navigate('/settings', { state: { tab: 'servers' } })}
>
<Settings size={12} />
{t('connection.serverSettings')}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, User } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
/**
* Modal shown when joining an Orbit session and the user has more than
* one account for the target server URL. Lets them pick which account
* to switch to before the join flow continues. Mount once in App.tsx
* any caller can invoke it via `useOrbitAccountPickerStore.request(...)`.
*/
export default function OrbitAccountPicker() {
const { t } = useTranslation();
const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore();
const [selected, setSelected] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Reset + focus first item each time the picker re-opens.
useEffect(() => {
if (!isOpen) return;
setSelected(0);
// Defer focus to the next tick so the DOM has actually mounted.
queueMicrotask(() => itemRefs.current[0]?.focus());
}, [isOpen]);
// Move DOM focus with the arrow-key selection so the browser's focus
// ring follows, and the currently active button is readable to AT.
useEffect(() => {
if (!isOpen) return;
itemRefs.current[selected]?.focus();
}, [selected, isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { cancel(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelected(s => (s + 1) % Math.max(1, accounts.length));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelected(s => (s - 1 + accounts.length) % Math.max(1, accounts.length));
} else if (e.key === 'Enter') {
e.preventDefault();
const target = accounts[selected];
if (target) pick(target);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, accounts, selected, pick, cancel]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={e => { if (e.target === e.currentTarget) cancel(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-account-picker-title"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div className="modal-content orbit-account-picker">
<button type="button" className="modal-close" onClick={cancel} aria-label={t('orbit.btnCancel')}>
<X size={18} />
</button>
<h3 id="orbit-account-picker-title" className="orbit-account-picker__title">
{t('orbit.accountPickerTitle')}
</h3>
<p className="orbit-account-picker__sub">
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
</p>
<ul className="orbit-account-picker__list" role="listbox">
{accounts.map((a, i) => (
<li key={a.id} role="option" aria-selected={i === selected}>
<button
ref={el => { itemRefs.current[i] = el; }}
type="button"
className={`orbit-account-picker__item${i === selected ? ' is-active' : ''}`}
onClick={() => pick(a)}
onMouseEnter={() => setSelected(i)}
>
<User size={14} />
<span className="orbit-account-picker__user">{a.username}</span>
{a.name && <span className="orbit-account-picker__name">· {a.name}</span>}
</button>
</li>
))}
</ul>
<div className="orbit-account-picker__actions">
<button type="button" className="btn btn-ghost" onClick={cancel}>
{t('orbit.btnCancel')}
</button>
</div>
</div>
</div>,
document.body,
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { leaveOrbitSession } from '../utils/orbit';
/**
* Orbit exit notification modal.
*
* Shown when:
* - `phase === 'ended'` (host closed the session; guest sees it)
* - `phase === 'error' && errorMessage === 'kicked'` (host permanently banned us)
* - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us;
* re-join via invite link still works)
*
* "OK" cleans up the guest-side outbox + resets the local store.
*/
export default function OrbitExitModal() {
const { t } = useTranslation();
const phase = useOrbitStore(s => s.phase);
const errorMessage = useOrbitStore(s => s.errorMessage);
const role = useOrbitStore(s => s.role);
const sessionName = useOrbitStore(s => s.state?.name);
const hostName = useOrbitStore(s => s.state?.host);
const isEnded = phase === 'ended';
const isKicked = phase === 'error' && errorMessage === 'kicked';
const isRemoved = phase === 'error' && errorMessage === 'removed';
const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout';
const isOpen = isEnded || isKicked || isRemoved || isHostTimeout;
const onOk = async () => {
try {
if (role === 'guest') await leaveOrbitSession();
else useOrbitStore.getState().reset();
} catch {
useOrbitStore.getState().reset();
}
};
// Modal is informational with a single action — Enter / Escape both fire OK.
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); void onOk(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
if (!isOpen) return null;
const title = isKicked
? t('orbit.exitKickedTitle')
: isRemoved
? t('orbit.exitRemovedTitle')
: isHostTimeout
? t('orbit.exitHostTimeoutTitle')
: t('orbit.exitEndedTitle');
const body = isKicked
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
: isRemoved
? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' })
: isHostTimeout
? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' })
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
return createPortal(
<div
className="modal-overlay orbit-exit-overlay"
onClick={e => { if (e.target === e.currentTarget) onOk(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-exit-title"
>
<div className="modal-content orbit-exit-modal">
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
<p className="orbit-exit-modal__body">{body}</p>
<div className="orbit-exit-modal__actions">
<button type="button" className="btn btn-primary" onClick={onOk} autoFocus>{t('orbit.exitOk')}</button>
</div>
</div>
</div>,
document.body,
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from 'react';
import { Radio, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import {
getSong,
buildCoverArtUrl,
coverArtCacheKey,
type SubsonicSong,
} from '../api/subsonic';
import CachedImage from './CachedImage';
import OrbitQueueHead from './OrbitQueueHead';
/**
* Orbit guest-side queue view.
*
* Rendered in place of the normal QueuePanel contents while `role === 'guest'`.
* Read-only: shows the host's current track + the host's actual upcoming
* play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). No
* reorder / remove / save those belong to the host.
*
* Track metadata is resolved lazily via `getSong` and cached locally so the
* list doesn't flicker while the 2.5 s state tick refreshes the snapshot.
*/
export default function OrbitGuestQueue() {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const pending = useOrbitStore(s => s.pendingSuggestions);
const queueItems = state?.playQueue ?? [];
const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
const currentTrack = state?.currentTrack ?? null;
// Local song cache — keyed by trackId. Survives parent re-renders triggered
// by the store tick so list rows don't remount and recomputed URLs don't
// kick off duplicate `getSong` calls.
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
// Track IDs we need but don't yet have. String-joined so useEffect deps
// stay stable across identical queue snapshots (e.g. reshuffle).
const wantedKey = useMemo(() => {
const ids: string[] = [];
if (currentTrack) ids.push(currentTrack.trackId);
queueItems.forEach(q => ids.push(q.trackId));
pending.forEach(id => ids.push(id));
return Array.from(new Set(ids)).sort().join('|');
}, [currentTrack, queueItems, pending]);
useEffect(() => {
const wanted = wantedKey ? wantedKey.split('|') : [];
const missing = wanted.filter(id => id && !songs[id]);
if (missing.length === 0) return;
let cancelled = false;
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
.then(results => {
if (cancelled) return;
setSongs(prev => {
const next = { ...prev };
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
return next;
});
});
return () => { cancelled = true; };
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (!state) return null;
const currentSong = currentTrack ? songs[currentTrack.trackId] : null;
return (
<div className="orbit-guest-queue">
<OrbitQueueHead state={state} />
{currentTrack && (
<div className="orbit-guest-queue__current">
<div className="orbit-guest-queue__live-badge">
<Radio size={10} /> {t('orbit.guestLive')}
</div>
<div className="orbit-guest-queue__current-body">
{currentSong?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(currentSong.coverArt, 96)}
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
alt=""
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{currentSong?.title ?? t('orbit.guestLoading')}
</div>
<div className="orbit-guest-queue__track-artist">
{currentSong?.artist ?? ''}
</div>
<div className="orbit-guest-queue__note">
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
</div>
</div>
</div>
</div>
)}
{pending.length > 0 && (
<div className="orbit-guest-queue__pending">
<div className="orbit-guest-queue__section-head orbit-guest-queue__section-head--pending">
<Clock size={11} />
<span>{t('orbit.guestPendingTitle')}</span>
<span className="orbit-guest-queue__count">{pending.length}</span>
</div>
{pending.map(trackId => {
const song = songs[trackId];
return (
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
{song?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 48)}
cacheKey={coverArtCacheKey(song.coverArt, 48)}
alt=""
className="orbit-guest-queue__cover"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">{song?.title ?? '…'}</div>
<div className="orbit-guest-queue__track-artist">{song?.artist ?? ''}</div>
<div className="orbit-guest-queue__pending-hint">{t('orbit.guestPendingHint')}</div>
</div>
</div>
);
})}
</div>
)}
<div className="orbit-guest-queue__section-head">
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
</div>
<div className="orbit-guest-queue__list">
{queueItems.length === 0 && (
<div className="orbit-guest-queue__empty">{t('orbit.guestUpNextEmpty')}</div>
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
const isHostPick = q.addedBy === state.host;
return (
<div
key={`${q.trackId}-${i}`}
className="orbit-guest-queue__item"
>
{song?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 48)}
cacheKey={coverArtCacheKey(song.coverArt, 48)}
alt=""
className="orbit-guest-queue__cover"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{song?.title ?? '…'}
</div>
<div className="orbit-guest-queue__track-artist">
{song?.artist ?? ''}
</div>
<div className="orbit-guest-queue__submitter">
{isHostPick
? t('orbit.queueAddedByHost')
: t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
</div>
</div>
);
})}
{truncatedBy > 0 && (
<div className="orbit-guest-queue__more">
{t('orbit.guestUpNextMore', { count: truncatedBy })}
</div>
)}
</div>
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
X, Sparkles, Users, Share2, LogIn, MousePointerClick,
ListMusic, Inbox, Sliders, LogOut,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useHelpModalStore } from '../store/helpModalStore';
import SettingsSubSection from './SettingsSubSection';
/**
* Orbit help modal. Rendered once at the app root; triggered from the
* launch popover ("How does this work?") and the in-session bar's help
* button. 9 accordion sections built on SettingsSubSection; all default
* closed so the modal opens compact. Does not touch playback.
*/
export default function OrbitHelpModal() {
const { t } = useTranslation();
const { isOpen, close } = useHelpModalStore();
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
// Focus the first accordion summary so arrow keys work immediately.
// Uses a short setTimeout because the browser re-focuses the clicked
// trigger button after the click handler returns — our focus call has
// to happen *after* that, otherwise the browser silently overrides it
// and the user only gets keyboard nav after pressing Tab first.
const id = window.setTimeout(() => {
const first = bodyRef.current?.querySelector<HTMLElement>('summary');
first?.focus();
}, 60);
return () => window.clearTimeout(id);
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const summaries = Array.from(
bodyRef.current?.querySelectorAll<HTMLElement>('summary') ?? [],
);
if (summaries.length === 0) return;
const current = document.activeElement as HTMLElement | null;
const idx = summaries.indexOf(current as HTMLElement);
e.preventDefault();
const next = e.key === 'ArrowDown'
? summaries[(idx + 1 + summaries.length) % summaries.length]
: summaries[(idx - 1 + summaries.length) % summaries.length];
next?.focus();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, close]);
if (!isOpen) return null;
const hostOnlyLabel = t('orbit.helpHostOnly');
return createPortal(
<div
className="modal-overlay"
onClick={e => { if (e.target === e.currentTarget) close(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-help-title"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div className="modal-content orbit-help-modal">
<button type="button" className="modal-close" onClick={close} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<h3 id="orbit-help-title" className="orbit-help-modal__title">
{t('orbit.helpTitle')}
</h3>
<p className="orbit-help-modal__intro">{t('orbit.helpIntro')}</p>
<div className="orbit-help-modal__body" ref={bodyRef}>
<SettingsSubSection title={t('orbit.helpSec1Title')} icon={<Sparkles size={16} />}>
<p>{t('orbit.helpSec1Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec2Title')} icon={<Users size={16} />}>
<p>{t('orbit.helpSec2Body')}</p>
<div className="orbit-help-modal__warn">
<strong>{t('orbit.helpSec2WarnHead')}</strong>
<span>{t('orbit.helpSec2WarnBody')}</span>
</div>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec3Title')} icon={<Share2 size={16} />}>
<p>{t('orbit.helpSec3Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec4Title')} icon={<LogIn size={16} />}>
<p>{t('orbit.helpSec4Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec5Title')} icon={<MousePointerClick size={16} />}>
<p>{t('orbit.helpSec5Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec6Title')} icon={<ListMusic size={16} />}>
<p>{t('orbit.helpSec6Body')}</p>
</SettingsSubSection>
<SettingsSubSection
title={`${t('orbit.helpSec7Title')} ${hostOnlyLabel}`}
icon={<Inbox size={16} />}
>
<p>{t('orbit.helpSec7Body')}</p>
</SettingsSubSection>
<SettingsSubSection
title={`${t('orbit.helpSec8Title')} ${hostOnlyLabel}`}
icon={<Sliders size={16} />}
>
<p>{t('orbit.helpSec8Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec9Title')} icon={<LogOut size={16} />}>
<p>{t('orbit.helpSec9Body')}</p>
</SettingsSubSection>
</div>
</div>
</div>,
document.body,
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, LogIn, ClipboardPaste } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import {
parseOrbitShareLink,
findSessionPlaylistId,
readOrbitState,
joinOrbitSession,
} from '../utils/orbit';
import { switchActiveServer } from '../utils/switchActiveServer';
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
import { showToast } from '../utils/toast';
interface Props {
onClose: () => void;
}
/**
* Orbit manual join modal. Alternative to the Ctrl+V paste shortcut for
* users who don't want to (or can't) paste the invite link into the app
* directly. Reuses the same parse + preflight pipeline the clipboard
* handler uses, so error surfaces stay consistent.
*/
export default function OrbitJoinModal({ onClose }: Props) {
const { t } = useTranslation();
const [link, setLink] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const onPaste = async () => {
try {
const clip = await navigator.clipboard.readText();
if (clip) setLink(clip);
} catch { /* silent — clipboard perms vary */ }
};
const onJoin = async () => {
setError(null);
const text = link.trim();
if (!text) { setError(t('orbit.joinErrEmpty')); return; }
const parsed = parseOrbitShareLink(text);
if (!parsed) { setError(t('orbit.joinErrInvalid')); return; }
const active = useAuthStore.getState().getActiveServer();
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
const wantUrl = parsed.serverBase.replace(/\/+$/, '');
setBusy(true);
try {
// Auto-switch to the link's server if the user has an account for it.
// Multiple candidates → picker modal. switch tears down any lingering
// orbit session.
if (activeUrl !== wantUrl) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
if (candidates.length === 0) {
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
return;
}
const target = candidates.length === 1
? candidates[0]
: await useOrbitAccountPickerStore.getState().request(candidates);
if (!target) { setBusy(false); return; }
const switched = await switchActiveServer(target);
if (!switched) {
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
return;
}
}
const playlistId = await findSessionPlaylistId(parsed.sid);
if (!playlistId) { setError(t('orbit.joinErrNotFound')); return; }
const state = await readOrbitState(playlistId);
if (!state) { setError(t('orbit.joinErrNotFound')); return; }
if (state.ended) { setError(t('orbit.joinErrEnded')); return; }
await joinOrbitSession(parsed.sid);
showToast(t('orbit.toastJoined'), 2200, 'info');
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : t('orbit.toastJoinFail'));
} finally {
setBusy(false);
}
};
return createPortal(
<div
className="modal-overlay orbit-start-overlay"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-join-title"
>
<div className="modal-content orbit-start-modal">
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<div className="orbit-start-modal__hero">
<div className="orbit-start-modal__hero-icon">
<LogIn size={24} />
</div>
<h3 id="orbit-join-title" className="orbit-start-modal__title">
{t('orbit.joinModalTitle')}
</h3>
<p className="orbit-start-modal__sub">{t('orbit.joinModalSub')}</p>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-join-link">
{t('orbit.joinModalLinkLabel')}
</label>
<div className="orbit-start-modal__input-row">
<input
id="orbit-join-link"
type="text"
autoFocus
value={link}
onChange={e => { setLink(e.target.value); setError(null); }}
onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }}
placeholder={t('orbit.joinModalLinkPlaceholder')}
className="orbit-start-modal__input"
/>
<button
type="button"
className="orbit-start-modal__reshuffle"
onClick={onPaste}
data-tooltip={t('orbit.joinModalPasteTooltip')}
aria-label={t('orbit.joinModalPasteTooltip')}
>
<ClipboardPaste size={15} />
</button>
</div>
<div className="orbit-start-modal__helper">{t('orbit.joinModalLinkHelper')}</div>
</div>
{error && <div className="orbit-start-modal__error">{error}</div>}
<div className="orbit-start-modal__actions">
<button type="button" className="btn btn-surface" onClick={onClose}>
{t('orbit.btnCancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={onJoin}
disabled={busy || !link.trim()}
>
{busy ? t('orbit.joinModalBusy') : t('orbit.joinModalSubmit')}
</button>
</div>
</div>
</div>,
document.body,
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit';
import ConfirmModal from './ConfirmModal';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
function joinedFor(fromMs: number, nowMs: number): string {
const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000));
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h${rm.toString().padStart(2, '0')}`;
}
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const popRef = useRef<HTMLDivElement>(null);
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
const nowMs = Date.now();
// Close on outside click / Escape — unless a confirm dialog is open
// (otherwise outside-clicking the modal would dismiss the popover too,
// and re-opening would lose the in-flight confirm context).
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (confirm) return;
const t = e.target as Node | null;
if (popRef.current?.contains(t)) return;
if (anchorRef.current?.contains(t)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => {
if (confirm) return;
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose, confirm]);
if (!state) return null;
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 12,
left: Math.max(8, anchor.left - 100),
zIndex: 9999,
}
: { display: 'none' };
const onConfirm = async () => {
if (!confirm) return;
const { user, mode } = confirm;
setConfirm(null);
if (mode === 'remove') await removeOrbitParticipant(user);
else await kickOrbitParticipant(user);
};
return createPortal(
<>
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
<div className="orbit-participants-pop__head">
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
</div>
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
<Crown size={13} />
<span className="orbit-participants-pop__name">{state.host}</span>
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
</div>
{state.participants.length === 0 && (
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
)}
{state.participants.map(p => {
const isMuted = state.suggestionBlocked?.includes(p.user) ?? false;
return (
<div key={p.user} className="orbit-participants-pop__row">
<User size={13} />
<span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
<div className="orbit-participants-pop__actions">
<button
type="button"
className={`orbit-participants-pop__kick${isMuted ? ' is-active' : ''}`}
onClick={() => { void setOrbitSuggestionBlocked(p.user, !isMuted); }}
data-tooltip={isMuted ? t('orbit.participantsUnmuteTooltip') : t('orbit.participantsMuteTooltip')}
aria-label={isMuted
? t('orbit.participantsUnmuteAria', { user: p.user })
: t('orbit.participantsMuteAria', { user: p.user })}
aria-pressed={isMuted}
>
{isMuted ? <MicOff size={12} /> : <Mic size={12} />}
</button>
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
data-tooltip={t('orbit.participantsRemoveTooltip')}
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
>
<UserMinus size={12} />
</button>
<button
type="button"
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
data-tooltip={t('orbit.participantsBanTooltip')}
aria-label={t('orbit.participantsBanAria', { user: p.user })}
>
<ShieldOff size={12} />
</button>
</div>
)}
</div>
);
})}
</div>
<ConfirmModal
open={!!confirm}
title={confirm?.mode === 'ban'
? t('orbit.confirmBanTitle')
: t('orbit.confirmRemoveTitle')}
message={confirm?.mode === 'ban'
? t('orbit.confirmBanBody', { user: confirm?.user ?? '' })
: t('orbit.confirmRemoveBody', { user: confirm?.user ?? '' })}
confirmLabel={confirm?.mode === 'ban'
? t('orbit.confirmBanConfirm')
: t('orbit.confirmRemoveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
danger={confirm?.mode === 'ban'}
onConfirm={() => { void onConfirm(); }}
onCancel={() => setConfirm(null)}
/>
</>,
document.body,
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Users, Wifi, WifiOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import type { OrbitState } from '../api/orbit';
interface Props {
state: OrbitState;
}
/** Host's state hasn't updated for this long → guest treats them as offline. */
const HOST_AWAY_THRESHOLD_MS = 15_000;
/**
* Shared Orbit head strip rendered at the top of the queue for both host
* and guest. Shows the session name and a comma-separated list of every
* participant (host first, then guests in join order).
*
* Guest view additionally surfaces host-presence: when the host's tick
* hasn't been seen for 15 s we render a subtle "host offline" badge so
* the guest knows the stalled playback isn't a local problem.
*/
export default function OrbitQueueHead({ state }: Props) {
const { t } = useTranslation();
const role = useOrbitStore(s => s.role);
const [nowMs, setNowMs] = useState(() => Date.now());
// Guest-only clock tick — React wouldn't re-render a stale state blob
// on its own, and the presence threshold is time-based.
useEffect(() => {
if (role !== 'guest') return;
const id = window.setInterval(() => setNowMs(Date.now()), 2000);
return () => window.clearInterval(id);
}, [role]);
const names = [state.host, ...state.participants.map(p => p.user)];
const showPresence = role === 'guest' && state.positionAt > 0;
const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS;
return (
<div className="orbit-queue-head">
<div className="orbit-queue-head__title-row">
<h2 className="orbit-queue-head__title">{state.name}</h2>
{showPresence && (
<span
className={`orbit-queue-head__presence orbit-queue-head__presence--${hostAway ? 'away' : 'online'}`}
role="status"
>
{hostAway ? <WifiOff size={11} /> : <Wifi size={11} />}
<span>{t(hostAway ? 'orbit.hostAway' : 'orbit.hostOnline')}</span>
</span>
)}
</div>
<div className="orbit-queue-head__meta">
<Users size={11} />
<span className="orbit-queue-head__names">{names.join(', ')}</span>
</div>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useHelpModalStore } from '../store/helpModalStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { getSong } from '../api/subsonic';
import {
endOrbitSession,
leaveOrbitSession,
computeOrbitDriftMs,
effectiveShuffleIntervalMs,
} from '../utils/orbit';
import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal';
import OrbitSettingsPopover from './OrbitSettingsPopover';
import OrbitSharePopover from './OrbitSharePopover';
import ConfirmModal from './ConfirmModal';
/**
* Orbit top-strip session indicator.
*
* Visible whenever the local store reports an active (or just-ended)
* session. Shows session name, host, participant count, shuffle countdown,
* and role-appropriate action buttons (catch-up for guests, exit for
* everyone).
*
* Deliberately low-chrome: sits above the rest of the app without
* reshaping the layout.
*/
const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000;
function formatCountdown(ms: number): string {
const clamped = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(clamped / 60);
const s = clamped % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function OrbitSessionBar() {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const errorMessage = useOrbitStore(s => s.errorMessage);
const [nowMs, setNowMs] = useState(() => Date.now());
const [peopleOpen, setPeopleOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
// Second-level tick just for the shuffle countdown + drift readout —
// the store itself only ticks at 2.5 s which is too coarse for a smooth
// countdown.
useEffect(() => {
if (!state || phase !== 'active') return;
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(id);
}, [state, phase]);
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
const shouldShowBar = !!state && (
phase === 'active'
|| phase === 'ended'
|| (phase === 'error' && (errorMessage === 'kicked' || errorMessage === 'removed'))
);
if (!shouldShowBar || !state) return (
<OrbitExitModal />
);
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
// Guest-only: detect drift from the host's estimated live position.
const guestPlayback = usePlayerStore.getState();
const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000);
const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId
? computeOrbitDriftMs(state, localPositionMs, nowMs)
: null;
const showCatchUp = role === 'guest'
&& state.isPlaying
&& state.currentTrack
&& (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS);
const performExit = async () => {
try {
if (role === 'host') await endOrbitSession();
else if (role === 'guest') await leaveOrbitSession();
else useOrbitStore.getState().reset();
} catch {
useOrbitStore.getState().reset();
}
};
const onExit = () => {
// Active-session exits get a confirm — guests don't want to drop out on
// a fat-finger, and the host's X ends the session for everyone, so
// accidentally clicking it is even worse. Post-end/kicked dismissals
// skip the confirm (the session is already over there).
if (phase === 'active' && (role === 'guest' || role === 'host')) {
setConfirmLeave(true);
return;
}
void performExit();
};
const onCatchUp = async () => {
if (!state.currentTrack) return;
const trackId = state.currentTrack.trackId;
const targetMs = estimateLivePosition(state, Date.now());
const targetSec = Math.max(0, targetMs / 1000);
const hostPlaying = state.isPlaying;
try {
const song = await getSong(trackId);
if (!song) return;
const track = songToTrack(song);
const player = usePlayerStore.getState();
const fraction = targetSec / Math.max(1, track.duration);
if (player.currentTrack?.id === trackId) {
player.seek(fraction);
if (hostPlaying && !player.isPlaying) player.resume();
else if (!hostPlaying && player.isPlaying) player.pause();
} else {
// Different track: play + seek on next tick once engine is ready.
player.playTrack(track, [track]);
window.setTimeout(() => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return;
p.seek(fraction);
if (!hostPlaying && p.isPlaying) p.pause();
}, 400);
}
} catch {
// silent — if the track is gone from the host's library, nothing we can do.
}
};
const participantCount = state.participants.length + 1; // +1 for the host
return (
<div className="orbit-bar">
<div className="orbit-bar__left">
<span className="orbit-bar__dot" aria-hidden="true" />
<span className="orbit-bar__name">{state.name}</span>
<span className="orbit-bar__sep">·</span>
<button
ref={peopleBtnRef}
type="button"
className="orbit-bar__count"
onClick={() => setPeopleOpen(v => !v)}
data-tooltip={t('orbit.participantsTooltip')}
aria-haspopup="menu"
aria-expanded={peopleOpen || undefined}
>
{participantCount}/{state.maxUsers}
</button>
<span className="orbit-bar__sep">·</span>
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
</div>
<div className="orbit-bar__center">
<span className="orbit-bar__shuffle">
<Shuffle size={13} className="orbit-bar__shuffle-icon" />
<span>{t('orbit.shuffleLabel')}</span>
<strong className="orbit-bar__shuffle-time">{formatCountdown(untilShuffle)}</strong>
</span>
</div>
<div className="orbit-bar__right">
{role === 'host' && (
<button
ref={settingsBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setSettingsOpen(v => !v)}
data-tooltip={t('orbit.settingsTooltip')}
aria-haspopup="menu"
aria-expanded={settingsOpen || undefined}
>
<Settings2 size={14} />
</button>
)}
{role === 'host' && (
<button
ref={shareBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setShareOpen(v => !v)}
data-tooltip={t('orbit.shareTooltip')}
aria-haspopup="menu"
aria-expanded={shareOpen || undefined}
aria-label={t('orbit.shareTooltip')}
>
<Share2 size={14} />
</button>
)}
{showCatchUp && (
<button
type="button"
className="orbit-bar__catchup"
onClick={onCatchUp}
data-tooltip={t('orbit.catchUpTooltip')}
>
<RefreshCw size={13} />
<span>{t('orbit.catchUpLabel')}</span>
</button>
)}
<button
type="button"
className="orbit-bar__settings"
onClick={() => useHelpModalStore.getState().open()}
data-tooltip={t('orbit.helpTooltip')}
aria-label={t('orbit.helpTooltip')}
>
<HelpCircle size={14} />
</button>
<button
type="button"
className="orbit-bar__exit"
onClick={onExit}
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
>
<X size={15} />
</button>
</div>
{peopleOpen && (
<OrbitParticipantsPopover
anchorRef={peopleBtnRef}
onClose={() => setPeopleOpen(false)}
/>
)}
{settingsOpen && (
<OrbitSettingsPopover
anchorRef={settingsBtnRef}
onClose={() => setSettingsOpen(false)}
/>
)}
{shareOpen && (
<OrbitSharePopover
anchorRef={shareBtnRef}
onClose={() => setShareOpen(false)}
/>
)}
<OrbitExitModal />
<ConfirmModal
open={confirmLeave}
title={role === 'host'
? t('orbit.confirmEndTitle')
: t('orbit.confirmLeaveTitle')}
message={role === 'host'
? t('orbit.confirmEndBody', { name: state.name })
: t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
confirmLabel={role === 'host'
? t('orbit.confirmEndConfirm')
: t('orbit.confirmLeaveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
danger={role === 'host'}
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
onCancel={() => setConfirmLeave(false)}
/>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Shuffle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit';
import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '../api/orbit';
import { showToast } from '../utils/toast';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
/**
* Host-only popover anchored below the settings button in the Orbit bar.
* Two toggles; writes are pushed immediately to Navidrome via
* `updateOrbitSettings`.
*/
export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const settings = useOrbitStore(s => s.state?.settings) ?? ORBIT_DEFAULT_SETTINGS;
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (popRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose]);
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 12,
right: Math.max(8, window.innerWidth - anchor.right),
zIndex: 9999,
}
: { display: 'none' };
return createPortal(
<div ref={popRef} className="orbit-settings-pop" style={style} role="menu">
<div className="orbit-settings-pop__head">{t('orbit.settingsTitle')}</div>
<label className="orbit-settings-pop__row">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingAutoApprove')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoApproveHint')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={settings.autoApprove}
onChange={e => { void updateOrbitSettings({ autoApprove: e.target.checked }); }}
/>
<span className="toggle-track" />
</span>
</label>
<label className="orbit-settings-pop__row">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingAutoShuffle')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoShuffleHint')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={settings.autoShuffle}
onChange={e => { void updateOrbitSettings({ autoShuffle: e.target.checked }); }}
/>
<span className="toggle-track" />
</span>
</label>
<div className="orbit-settings-pop__row orbit-settings-pop__row--stacked">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingShuffleInterval')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingShuffleIntervalHint')}</div>
</div>
<div
className="orbit-settings-pop__preset-group"
role="radiogroup"
aria-label={t('orbit.settingShuffleInterval')}
>
{ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => {
const active = (settings.shuffleIntervalMin ?? 15) === min;
return (
<button
key={min}
type="button"
role="radio"
aria-checked={active}
className={`orbit-settings-pop__preset${active ? ' is-active' : ''}`}
disabled={!settings.autoShuffle}
onClick={() => {
void updateOrbitSettings({ shuffleIntervalMin: min as OrbitShuffleIntervalMin });
}}
>
{t('orbit.settingShuffleIntervalValue', { count: min })}
</button>
);
})}
</div>
</div>
<button
type="button"
className="orbit-settings-pop__action"
onClick={() => {
void triggerOrbitShuffleNow();
showToast(t('orbit.toastShuffled'), 2200, 'info');
onClose();
}}
>
<Shuffle size={13} />
<span>{t('orbit.settingShuffleNow')}</span>
</button>
</div>,
document.body,
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Copy, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { buildOrbitShareLink } from '../utils/orbit';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
/**
* Host-only popover anchored below the share button in the Orbit bar.
* Surfaces the session invite link with a copy affordance. Lives on its
* own so the participants popover can stay focused on participants.
*/
export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const shareLink = sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
useEffect(() => {
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (popRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose]);
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
if (!shareLink) return null;
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 12,
right: Math.max(8, window.innerWidth - anchor.right),
zIndex: 9999,
}
: { display: 'none' };
return createPortal(
<div ref={popRef} className="orbit-share-pop" style={style} role="menu">
<div className="orbit-share-pop__label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-share-pop__row">
<code className="orbit-share-pop__link">{shareLink}</code>
<button
type="button"
className="orbit-share-pop__copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>,
document.body,
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
X, Check, Copy, Orbit as OrbitIcon,
Dices, AlertTriangle, Globe2,
} from 'lucide-react';
import {
startOrbitSession,
buildOrbitShareLink,
generateSessionId,
} from '../utils/orbit';
import { randomOrbitSessionName } from '../utils/orbitNames';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { isLanUrl } from '../hooks/useConnectionStatus';
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
interface Props { onClose: () => void; }
/**
* Orbit start-session modal.
*
* One-screen flow: a share-link is shown immediately (built from a
* pre-generated session id + a slug derived from the live name). The host
* can copy it any time; pressing "Start" creates the session under that
* same id and auto-copies the link if it hasn't been copied yet.
*/
export default function OrbitStartModal({ onClose }: Props) {
const { t } = useTranslation();
const [sid] = useState(() => generateSessionId());
const [name, setName] = useState(() => randomOrbitSessionName());
const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [hasCopied, setHasCopied] = useState(false);
const [clearQueue, setClearQueue] = useState(false);
const server = useAuthStore.getState().getActiveServer();
const serverBase = server?.url ?? '';
const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer');
const onLan = isLanUrl(serverBase);
const shareLink = useMemo(
() => buildOrbitShareLink(serverBase, sid),
[serverBase, sid],
);
const writeLinkToClipboard = async (): Promise<boolean> => {
try {
await navigator.clipboard.writeText(shareLink);
return true;
} catch {
return false;
}
};
const onCopy = async () => {
const ok = await writeLinkToClipboard();
if (ok) {
setCopied(true);
setHasCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
const onStart = async () => {
setError(null);
const trimmed = name.trim();
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
if (!hasCopied) {
const ok = await writeLinkToClipboard();
if (ok) setHasCopied(true);
}
setBusy(true);
try {
if (clearQueue) usePlayerStore.getState().clearQueue();
await startOrbitSession({ name: trimmed, maxUsers, sid });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : t('orbit.errStartFailed'));
} finally {
setBusy(false);
}
};
const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName));
return createPortal(
<div
className="modal-overlay orbit-start-overlay"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-start-title"
>
<div className="modal-content orbit-start-modal">
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<div className="orbit-start-modal__hero">
<div className="orbit-start-modal__hero-icon">
<OrbitIcon size={24} />
</div>
<h3 id="orbit-start-title" className="orbit-start-modal__title">
{t('orbit.heroTitlePrefix')}{' '}
<span className="orbit-start-modal__brand">{t('orbit.heroTitleBrand')}</span>
</h3>
<p className="orbit-start-modal__sub">
{heroSubParts[0]}
<strong>{serverName}</strong>
{heroSubParts[1] ?? ''}
</p>
</div>
<div
className={`orbit-start-modal__tip${onLan ? ' orbit-start-modal__tip--warn' : ''}`}
role={onLan ? 'alert' : undefined}
>
{onLan ? <AlertTriangle size={15} /> : <Globe2 size={15} />}
<span>{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}</span>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-name">
{t('orbit.labelName')}
</label>
<div className="orbit-start-modal__input-row">
<input
id="orbit-name"
type="text"
autoFocus
value={name}
onChange={e => { setName(e.target.value); setHasCopied(false); }}
onKeyDown={e => {
if (e.key !== 'Enter') return;
if (busy || !name.trim()) return;
e.preventDefault();
void onStart();
}}
placeholder={t('orbit.namePlaceholder')}
maxLength={40}
className="orbit-start-modal__input"
/>
<button
type="button"
className="orbit-start-modal__reshuffle"
onClick={() => { setName(randomOrbitSessionName()); setHasCopied(false); }}
data-tooltip={t('orbit.reshuffleTooltip')}
aria-label={t('orbit.reshuffleAria')}
>
<Dices size={15} />
</button>
</div>
<div className="orbit-start-modal__helper">{t('orbit.helperName')}</div>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-max">
{t('orbit.labelMax')}: <strong>{maxUsers}</strong>
</label>
<input
id="orbit-max"
type="range"
min={1}
max={32}
value={maxUsers}
onChange={e => setMaxUsers(Number(e.target.value))}
className="orbit-start-modal__range"
/>
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__toggle-row">
<div className="orbit-start-modal__toggle-text">
<div className="orbit-start-modal__label">{t('orbit.labelClearQueue')}</div>
<div className="orbit-start-modal__helper">{t('orbit.helperClearQueue')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={clearQueue}
onChange={e => setClearQueue(e.target.checked)}
/>
<span className="toggle-track" />
</span>
</label>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
<div className="orbit-start-modal__link">
<code>{shareLink}</code>
<button
type="button"
className="orbit-start-modal__copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
<div className="orbit-start-modal__helper">{t('orbit.helperLink')}</div>
</div>
{error && <div className="orbit-start-modal__error">{error}</div>}
<div className="orbit-start-modal__actions">
<button type="button" className="btn btn-surface" onClick={onClose}>
{t('orbit.btnCancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={onStart}
disabled={busy || !name.trim()}
>
{busy
? t('orbit.btnStarting')
: hasCopied ? t('orbit.btnStart') : t('orbit.btnCopyAndStart')}
</button>
</div>
</div>
</div>,
document.body,
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { useHelpModalStore } from '../store/helpModalStore';
import OrbitStartModal from './OrbitStartModal';
import OrbitJoinModal from './OrbitJoinModal';
import OrbitWordmark from './OrbitWordmark';
/**
* Topbar trigger opens a small launch popover offering three choices:
* create a new session, join an existing one via invite link, or open the
* Orbit help section. Hidden while a session is already active so we
* don't offer entry points while the user's session bar is already live.
*/
export default function OrbitStartTrigger() {
const { t } = useTranslation();
const role = useOrbitStore(s => s.role);
const visible = useAuthStore(s => s.showOrbitTrigger);
const [popoverOpen, setPopoverOpen] = useState(false);
const [startOpen, setStartOpen] = useState(false);
const [joinOpen, setJoinOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
// Close popover on outside click / Escape.
useEffect(() => {
if (!popoverOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (popRef.current?.contains(target)) return;
if (btnRef.current?.contains(target)) return;
setPopoverOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [popoverOpen]);
if (role !== null) return null;
if (!visible) return null;
const anchor = btnRef.current?.getBoundingClientRect();
const popoverStyle: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 8,
left: anchor.left,
zIndex: 9999,
}
: { display: 'none' };
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
return (
<>
<button
ref={btnRef}
type="button"
className="btn btn-surface orbit-start-trigger"
onClick={() => setPopoverOpen(v => !v)}
data-tooltip={t('orbit.triggerTooltip')}
data-tooltip-pos="bottom"
aria-haspopup="menu"
aria-expanded={popoverOpen || undefined}
aria-label={t('orbit.triggerLabel')}
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<OrbitIcon size={18} className="orbit-start-trigger__spin" />
<span style={{ display: 'inline-flex', alignItems: 'center', height: '1.5em' }}>
<OrbitWordmark height={14} />
</span>
</button>
{popoverOpen && createPortal(
<div ref={popRef} className="orbit-launch-pop" style={popoverStyle} role="menu">
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
<Plus size={14} />
<span>{t('orbit.launchCreate')}</span>
</button>
<button type="button" className="orbit-launch-pop__item" onClick={pickJoin}>
<LogIn size={14} />
<span>{t('orbit.launchJoin')}</span>
</button>
<button
type="button"
className="orbit-launch-pop__item"
onClick={pickHelp}
>
<HelpCircle size={14} />
<span>{t('orbit.launchHelp')}</span>
</button>
</div>,
document.body,
)}
{startOpen && <OrbitStartModal onClose={() => setStartOpen(false)} />}
{joinOpen && <OrbitJoinModal onClose={() => setJoinOpen(false)} />}
</>
);
}
File diff suppressed because one or more lines are too long
+141
View File
@@ -0,0 +1,141 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
export type OverlayScrollAreaProps = {
children: React.ReactNode;
/** Optional handler on the outer wrapper (e.g. mini queue DnD hit-testing). */
onMouseMove?: React.MouseEventHandler<HTMLDivElement>;
/** Classes on the outer wrapper (e.g. queue-list-wrap, mini-queue-wrap). */
className?: string;
/** Classes on the scrollable viewport (e.g. queue-list, mini-queue). */
viewportClassName?: string;
/** Serialized internally — triggers remeasure + ResizeObserver refresh. */
measureDeps?: ReadonlyArray<unknown>;
/** Vertical inset of the hit rail (align with viewport padding). */
railInset?: OverlayScrollRailInset;
/** e.g. during native DnD — scroll-behavior: auto on the viewport. */
viewportScrollBehaviorAuto?: boolean;
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
viewportRef?: React.Ref<HTMLDivElement>;
/** Optional id on the viewport (e.g. main app scroll for route pages). */
viewportId?: string;
};
const RAIL_INSET_CLASS: Record<OverlayScrollRailInset, string> = {
none: 'overlay-scroll--rail-inset-none',
mini: 'overlay-scroll--rail-inset-mini',
panel: 'overlay-scroll--rail-inset-panel',
};
function assignRef<T>(ref: React.Ref<T> | undefined, value: T) {
if (ref == null) return;
if (typeof ref === 'function') ref(value);
else (ref as { current: T | null }).current = value;
}
export default function OverlayScrollArea({
children,
onMouseMove,
className = '',
viewportClassName = '',
measureDeps = [],
railInset = 'none',
viewportScrollBehaviorAuto = false,
viewportRef: viewportRefProp,
viewportId,
}: OverlayScrollAreaProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
const recompute = useCallback(() => {
const vp = viewportRef.current;
const wrap = wrapRef.current;
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
const trackH =
rail && rail.clientHeight > 0 ? rail.clientHeight : undefined;
setMeta(computeOverlayScrollbarThumbMeta(vp, trackH));
}, []);
const measureKey = JSON.stringify(measureDeps ?? []);
useLayoutEffect(() => {
if (!meta.visible) return;
const vp = viewportRef.current;
const wrap = wrapRef.current;
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
const th = rail?.clientHeight;
if (!vp || !th || th <= 0) return;
setMeta((prev) => {
const next = computeOverlayScrollbarThumbMeta(vp, th);
if (
prev.thumbH === next.thumbH &&
prev.thumbT === next.thumbT &&
prev.visible === next.visible
) {
return prev;
}
return next;
});
}, [meta.visible]);
useEffect(() => {
recompute();
const wrap = wrapRef.current;
const onWinResize = () => recompute();
window.addEventListener('resize', onWinResize);
const ro =
typeof ResizeObserver !== 'undefined' && wrap
? new ResizeObserver(() => recompute())
: null;
if (ro && wrap) ro.observe(wrap);
return () => {
window.removeEventListener('resize', onWinResize);
ro?.disconnect();
};
}, [recompute, measureKey]);
const setViewportNode = (el: HTMLDivElement | null) => {
viewportRef.current = el;
assignRef(viewportRefProp, el);
};
const rootClass = [
'overlay-scroll',
RAIL_INSET_CLASS[railInset],
viewportScrollBehaviorAuto ? 'overlay-scroll--viewport-scroll-auto' : '',
className,
]
.filter(Boolean)
.join(' ');
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
return (
<div ref={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
<div
id={viewportId}
ref={setViewportNode}
className={viewportClass}
onScroll={recompute}
>
{children}
</div>
{meta.visible && (
<div className="overlay-scroll__rail" aria-hidden>
<div
className="overlay-scroll__thumb"
style={{
height: `${meta.thumbH}px`,
transform: `translateY(${meta.thumbT}px)`,
}}
onPointerDown={(ev) => bindOverlayScrollbarThumbDrag(ev, viewportRef.current)}
/>
</div>
)}
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { decodeSharePayloadFromText } from '../utils/shareLink';
import { decodeServerMagicStringFromText } from '../utils/serverMagicString';
import { applySharePastePayload } from '../utils/applySharePaste';
import { showToast } from '../utils/toast';
import {
parseOrbitShareLink,
joinOrbitSession,
findSessionPlaylistId,
readOrbitState,
OrbitJoinError,
} from '../utils/orbit';
import { switchActiveServer } from '../utils/switchActiveServer';
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
import ConfirmModal from './ConfirmModal';
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
'not-found': 'orbit.joinErrNotFound',
'ended': 'orbit.joinErrEnded',
'full': 'orbit.joinErrFull',
'kicked': 'orbit.joinErrKicked',
'no-user': 'orbit.joinErrNoUser',
'server-error': 'orbit.joinErrServerError',
};
/**
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
* outside text fields. Shares require login; invites open add-server (settings or login).
*/
export default function PasteClipboardHandler() {
const navigate = useNavigate();
const { t } = useTranslation();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const busy = useRef(false);
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
const [orbitInvalid, setOrbitInvalid] = useState(false);
// `not-found` and `ended` collapse into a single "link no longer valid"
// dialog — from the guest's POV both mean the same thing: the invite
// doesn't lead anywhere any more. Other reasons stay as toasts because
// they're actionable (full → wait, kicked → talk to host, etc.).
const handleJoinError = (reason: string | null, fallback?: string) => {
if (reason === 'not-found' || reason === 'ended') {
setOrbitInvalid(true);
return;
}
const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null;
showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error');
};
const runOrbitJoin = (sid: string) => {
if (busy.current) return;
busy.current = true;
joinOrbitSession(sid)
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
.catch(err => {
if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message);
else handleJoinError(null);
})
.finally(() => { busy.current = false; });
};
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
const text = e.clipboardData?.getData('text/plain') ?? '';
// Orbit share link — handled before library shares.
const orbit = parseOrbitShareLink(text.trim());
if (orbit) {
e.preventDefault();
e.stopPropagation();
if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; }
if (busy.current) return;
busy.current = true;
(async () => {
const active = useAuthStore.getState().getActiveServer();
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
// Auto-switch to the link's target server if the user has an
// account registered for it. No account → clear error. Multiple
// accounts for the same URL → picker lets the user choose. The
// switch itself tears down any lingering orbit session (see
// switchActiveServer) so the join below starts clean.
if (activeUrl !== wantUrl) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
if (candidates.length === 0) {
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
return;
}
const target = candidates.length === 1
? candidates[0]
: await useOrbitAccountPickerStore.getState().request(candidates);
if (!target) return; // cancelled
const switched = await switchActiveServer(target);
if (!switched) {
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
return;
}
}
// Preview the session state so the confirm dialog can show the
// host and session name. Failures surface the same error toasts
// the join would, without ever showing the confirm.
const playlistId = await findSessionPlaylistId(orbit.sid);
if (!playlistId) { handleJoinError('not-found'); return; }
const state = await readOrbitState(playlistId);
if (!state) { handleJoinError('not-found'); return; }
if (state.ended) { handleJoinError('ended'); return; }
setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name });
})()
.catch(() => handleJoinError(null))
.finally(() => { busy.current = false; });
return;
}
const share = decodeSharePayloadFromText(text);
if (share) {
if (!isLoggedIn) {
e.preventDefault();
e.stopPropagation();
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
return;
}
if (busy.current) {
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
busy.current = true;
void applySharePastePayload(share, navigate, t).finally(() => {
busy.current = false;
});
return;
}
const invite = decodeServerMagicStringFromText(text);
if (!invite) return;
if (busy.current) {
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
busy.current = true;
if (isLoggedIn) {
navigate('/settings', { state: { tab: 'server' as const, openAddServerInvite: invite } });
} else {
navigate('/login', { state: { openAddServerInvite: invite } });
}
queueMicrotask(() => {
busy.current = false;
});
};
document.addEventListener('paste', onPaste, true);
return () => document.removeEventListener('paste', onPaste, true);
}, [navigate, t, isLoggedIn]);
return (
<>
<ConfirmModal
open={!!orbitConfirm}
title={t('orbit.confirmJoinTitle')}
message={t('orbit.confirmJoinBody', {
host: orbitConfirm?.host ?? '',
name: orbitConfirm?.name ?? '',
})}
confirmLabel={t('orbit.confirmJoinConfirm')}
cancelLabel={t('orbit.confirmCancel')}
onConfirm={() => {
const sid = orbitConfirm?.sid;
setOrbitConfirm(null);
if (sid) runOrbitJoin(sid);
}}
onCancel={() => setOrbitConfirm(null)}
/>
<ConfirmModal
open={orbitInvalid}
title={t('orbit.invalidLinkTitle')}
message={t('orbit.invalidLinkBody')}
confirmLabel={t('orbit.exitOk')}
onConfirm={() => setOrbitInvalid(false)}
/>
</>
);
}
+299
View File
@@ -0,0 +1,299 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, Moon, Sunrise } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import type { TFunction } from 'i18next';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
function formatClockTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
/** One tap = schedule; custom minutes still covers any duration. */
const PRESET_SECONDS = [30, 60, 120, 300, 600, 900, 1800, 3600] as const;
function formatPresetLabel(seconds: number, t: TFunction): string {
if (seconds < 60) return t('player.delayFmtSec', { n: seconds });
if (seconds < 3600) return t('player.delayFmtMin', { n: seconds / 60 });
return t('player.delayFmtHr', { n: seconds / 3600 });
}
function computeAnchoredPanelStyle(anchorEl: HTMLElement): React.CSSProperties {
const ar = anchorEl.getBoundingClientRect();
const mw = Math.min(360, Math.max(200, window.innerWidth - 32));
let left = ar.left + ar.width / 2 - mw / 2;
const pad = 12;
left = Math.max(pad, Math.min(left, window.innerWidth - mw - pad));
const gap = 10;
return {
position: 'fixed',
left,
bottom: window.innerHeight - ar.top + gap,
width: mw,
maxWidth: 360,
margin: 0,
maxHeight: 'min(72vh, calc(100vh - 24px))',
overflowY: 'auto',
};
}
export interface PlaybackDelayModalProps {
open: boolean;
onClose: () => void;
/** When set, panel is fixed just above this element (transport strip). */
anchorRef?: React.RefObject<HTMLElement | null>;
}
export default function PlaybackDelayModal({ open, onClose, anchorRef }: PlaybackDelayModalProps) {
const { t } = useTranslation();
const {
isPlaying,
currentTrack,
currentRadio,
scheduledPauseAtMs,
scheduledResumeAtMs,
schedulePauseIn,
scheduleResumeIn,
clearScheduledPause,
clearScheduledResume,
} = usePlayerStore(
useShallow(s => ({
isPlaying: s.isPlaying,
currentTrack: s.currentTrack,
currentRadio: s.currentRadio,
scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledResumeAtMs: s.scheduledResumeAtMs,
schedulePauseIn: s.schedulePauseIn,
scheduleResumeIn: s.scheduleResumeIn,
clearScheduledPause: s.clearScheduledPause,
clearScheduledResume: s.clearScheduledResume,
})),
);
const [nowTick, setNowTick] = useState(() => Date.now());
const [posTick, setPosTick] = useState(0);
const [customMinutes, setCustomMinutes] = useState('');
/** Preset-seconds the user is currently hovering — drives the live "Pauses at HH:MM" preview. */
const [hoverSeconds, setHoverSeconds] = useState<number | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setCustomMinutes('');
setHoverSeconds(null);
}, [open]);
// While modal is open, refresh the "now" tick every second so the live
// preview clock stays accurate.
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNowTick(Date.now()), 1000);
return () => window.clearInterval(id);
}, [open]);
useEffect(() => {
if (!open) return;
if (scheduledPauseAtMs == null && scheduledResumeAtMs == null) return;
const id = window.setInterval(() => setNowTick(Date.now()), 500);
return () => window.clearInterval(id);
}, [open, scheduledPauseAtMs, scheduledResumeAtMs]);
useEffect(() => {
if (!open || !anchorRef) return;
const bump = () => setPosTick(x => x + 1);
window.addEventListener('resize', bump);
window.addEventListener('scroll', bump, true);
return () => {
window.removeEventListener('resize', bump);
window.removeEventListener('scroll', bump, true);
};
}, [open, anchorRef]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const canPauseLater = isPlaying && (!!currentTrack || !!currentRadio);
const canStartLater = !isPlaying && (!!currentTrack || !!currentRadio);
const customSeconds = useMemo(() => {
const n = parseFloat(customMinutes.replace(',', '.'));
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 60);
}, [customMinutes]);
const applyPause = (sec: number) => {
schedulePauseIn(sec);
onClose();
};
const applyStart = (sec: number) => {
scheduleResumeIn(sec);
onClose();
};
const useAnchor = !!anchorRef;
const anchorEl = anchorRef?.current ?? null;
void posTick;
const anchoredPanelStyle =
open && useAnchor && anchorEl ? computeAnchoredPanelStyle(anchorEl) : undefined;
const heading =
canPauseLater ? t('player.delayPauseSection') : canStartLater ? t('player.delayStartSection') : t('player.delayModalTitle');
// Mode determines icon + colour accent ("mood") of the modal.
const mode: 'pause' | 'start' | 'idle' =
canPauseLater ? 'pause' : canStartLater ? 'start' : 'idle';
const HeadingIcon = mode === 'pause' ? Moon : mode === 'start' ? Sunrise : null;
// Live preview: seconds that would be applied right now if the user clicked.
// Priority: hovered chip → typed custom minutes → nothing.
const previewSeconds = hoverSeconds ?? customSeconds;
const previewAtMs = previewSeconds != null ? nowTick + previewSeconds * 1000 : null;
const previewClock = previewAtMs != null ? formatClockTime(previewAtMs) : null;
if (!open) return null;
const defaultPanelStyle: React.CSSProperties = { maxWidth: 360, width: 'min(360px, calc(100vw - 32px))' };
const panelStyle = anchoredPanelStyle ? { ...defaultPanelStyle, ...anchoredPanelStyle } : defaultPanelStyle;
const scheduledAt = canPauseLater ? scheduledPauseAtMs : canStartLater ? scheduledResumeAtMs : null;
const clearScheduled = canPauseLater ? clearScheduledPause : canStartLater ? clearScheduledResume : null;
const cancelLabel = canPauseLater ? t('player.delayCancelPause') : t('player.delayCancelStart');
const apply = canPauseLater ? applyPause : applyStart;
return createPortal(
<div
className={`modal-overlay playback-delay-modal-overlay${useAnchor ? ' playback-delay-modal-overlay--anchored' : ''}`}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="playback-delay-modal-title"
style={
useAnchor
? { alignItems: 'stretch', justifyContent: 'flex-start', padding: 0 }
: { alignItems: 'center', paddingTop: 0 }
}
>
<div
className={`modal-content playback-delay-modal playback-delay-modal--${mode}`}
data-pd-mode={mode}
onClick={e => e.stopPropagation()}
style={panelStyle}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t('player.closeDelayModal')}>
<X size={18} />
</button>
<div className="playback-delay-modal__head">
{HeadingIcon && (
<span className="playback-delay-modal__icon" aria-hidden="true">
<HeadingIcon size={18} />
</span>
)}
<h3 id="playback-delay-modal-title" className="playback-delay-modal__title">
{heading}
</h3>
</div>
{(canPauseLater || canStartLater) && (
<>
{scheduledAt != null && (
<div className="playback-delay-section__head playback-delay-section__head--tight">
<span className="playback-delay-section__countdown">
{t('player.delayIn')} {formatPlaybackScheduleRemaining(scheduledAt, nowTick)}
</span>
{clearScheduled && (
<button
type="button"
className="btn btn-ghost btn-sm playback-delay-inline-cancel"
aria-label={cancelLabel}
onClick={() => clearScheduled()}
>
{t('player.delayCancel')}
</button>
)}
</div>
)}
<div
className="playback-delay-chips playback-delay-chips--compact"
onMouseLeave={() => setHoverSeconds(null)}
>
{PRESET_SECONDS.map(sec => (
<button
key={`pr-${sec}`}
type="button"
className="playback-delay-chip"
onMouseEnter={() => setHoverSeconds(sec)}
onFocus={() => setHoverSeconds(sec)}
onBlur={() => setHoverSeconds(null)}
onClick={() => apply(sec)}
>
{formatPresetLabel(sec, t)}
</button>
))}
</div>
<div className="playback-delay-custom playback-delay-custom--inline">
<div className="playback-delay-custom__field">
<input
ref={customInputRef}
id="playback-delay-custom-min"
type="text"
inputMode="decimal"
className="playback-delay-custom__input"
placeholder={t('player.delayCustomPlaceholder')}
value={customMinutes}
onChange={e => setCustomMinutes(e.target.value)}
aria-label={t('player.delayCustomMinutes')}
/>
<span className="playback-delay-custom__suffix" aria-hidden="true">min</span>
</div>
<button
type="button"
className="btn btn-primary"
disabled={customSeconds == null}
aria-label={canPauseLater ? t('player.delaySchedulePause') : t('player.delayScheduleStart')}
onClick={() => { if (customSeconds != null) apply(customSeconds); }}
>
{t('player.delayApply')}
</button>
</div>
<div
className="playback-delay-preview"
aria-live="polite"
data-empty={previewClock == null ? 'true' : 'false'}
>
{previewClock != null && (
<>
<span className="playback-delay-preview__label">
{canPauseLater ? t('player.delayPreviewPause') : t('player.delayPreviewStart')}
</span>
<span className="playback-delay-preview__time">{previewClock}</span>
</>
)}
</div>
</>
)}
{!canPauseLater && !canStartLater && (
<div className="playback-delay-idle">
<p className="playback-delay-muted">{t('player.delayInactivePause')}</p>
<p className="playback-delay-muted">{t('player.delayInactiveStart')}</p>
</div>
)}
</div>
</div>,
document.body,
);
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useEffect, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
export interface PlaybackScheduleBadgeProps {
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */
layoutAnchorRef: React.RefObject<HTMLElement | null>;
/** Extra class on the portaled ring (e.g. fullscreen sizing). */
className?: string;
}
/**
* Circular progress ring around the play/pause button, portaled to document.body
* so it is never clipped by `contain: paint` on the player bar.
*
* - Accent-coloured SVG stroke with a gradient; depletes as the deadline approaches.
* - Colour shifts to a warm warning hue when <10 % of the scheduled time remains.
* - The remaining time is rendered _inside_ the button (replaces the
* Play/Pause icon) by the consuming view, not here avoids the floating
* pill clipping against the viewport edge.
*/
export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: PlaybackScheduleBadgeProps) {
const { t } = useTranslation();
const {
isPlaying,
scheduledPauseAtMs,
scheduledPauseStartMs,
scheduledResumeAtMs,
scheduledResumeStartMs,
} = usePlayerStore(
useShallow(s => ({
isPlaying: s.isPlaying,
scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledPauseStartMs: s.scheduledPauseStartMs,
scheduledResumeAtMs: s.scheduledResumeAtMs,
scheduledResumeStartMs: s.scheduledResumeStartMs,
})),
);
// Active timer: pause if playing, resume if paused.
const deadlineMs = isPlaying ? scheduledPauseAtMs : scheduledResumeAtMs;
const startMs = isPlaying ? scheduledPauseStartMs : scheduledResumeStartMs;
const [nowMs, setNowMs] = useState(() => Date.now());
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => {
if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id);
}, [deadlineMs, windowHidden]);
useLayoutEffect(() => {
if (deadlineMs == null || windowHidden) return;
const el = layoutAnchorRef.current;
if (!el) return;
const sync = () => {
const r = el.getBoundingClientRect();
setAnchorRect({
left: r.left + r.width / 2,
top: r.top + r.height / 2,
size: Math.max(r.width, r.height),
});
};
sync();
window.addEventListener('resize', sync);
window.addEventListener('scroll', sync, true);
const iv = window.setInterval(sync, 400);
return () => {
window.removeEventListener('resize', sync);
window.removeEventListener('scroll', sync, true);
window.clearInterval(iv);
};
}, [deadlineMs, layoutAnchorRef, windowHidden]);
if (deadlineMs == null || startMs == null || !anchorRect) return null;
const totalMs = Math.max(1, deadlineMs - startMs);
const remainingMs = Math.max(0, deadlineMs - nowMs);
const progress = Math.min(1, Math.max(0, 1 - remainingMs / totalMs)); // 0 → just armed, 1 → fires now
const nearEnd = remainingMs / totalMs < 0.1;
const label = isPlaying && scheduledPauseAtMs != null
? `${t('player.delayPauseSection')}: ${t('player.delayIn')} ${formatPlaybackScheduleRemaining(deadlineMs, nowMs)}`
: `${t('player.delayStartSection')}: ${t('player.delayIn')} ${formatPlaybackScheduleRemaining(deadlineMs, nowMs)}`;
// Ring sits snug around the button; diameter ~1.22× button size for breathing room.
const ringSize = Math.round(anchorRect.size * 1.22);
const strokeW = Math.max(2.5, ringSize / 28);
const r = ringSize / 2 - strokeW / 2;
const circ = 2 * Math.PI * r;
// Reversed direction so the ring shrinks counter-clockwise from the top.
const dashOffset = -circ * progress;
// Mode selects the gradient tint: pause = lavender, start = peach.
const mode: 'pause' | 'start' = isPlaying ? 'pause' : 'start';
// Uniqueish gradient id — multiple badges (player bar + fullscreen) can coexist.
const gradId = `psy-sched-grad-${mode}`;
const wrapStyle: React.CSSProperties = {
position: 'fixed',
left: anchorRect.left,
top: anchorRect.top,
transform: 'translate(-50%, -50%)',
width: ringSize,
height: ringSize,
zIndex: 9998,
pointerEvents: 'none',
};
return createPortal(
<span
className={[
'playback-schedule-ring',
`playback-schedule-ring--${mode}`,
nearEnd ? 'is-warn' : '',
className,
].filter(Boolean).join(' ')}
style={wrapStyle}
aria-label={label}
>
<svg
className="playback-schedule-ring__svg"
width={ringSize}
height={ringSize}
viewBox={`0 0 ${ringSize} ${ringSize}`}
aria-hidden="true"
>
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" className="playback-schedule-ring__grad-a" />
<stop offset="100%" className="playback-schedule-ring__grad-b" />
</linearGradient>
</defs>
<circle
className="playback-schedule-ring__track"
cx={ringSize / 2}
cy={ringSize / 2}
r={r}
fill="none"
strokeWidth={strokeW}
/>
<circle
className="playback-schedule-ring__fill"
cx={ringSize / 2}
cy={ringSize / 2}
r={r}
fill="none"
stroke={`url(#${gradId})`}
strokeWidth={strokeW}
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={dashOffset}
transform={`rotate(-90 ${ringSize / 2} ${ringSize / 2})`}
/>
</svg>
</span>,
document.body,
);
}
+122 -13
View File
@@ -2,11 +2,14 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
@@ -18,6 +21,10 @@ import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
import LastfmIcon from './LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -41,11 +48,28 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
return <span className={className} ref={spanRef} />;
});
// Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render.
const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const updateRemaining = () => {
if (spanRef.current) {
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime);
spanRef.current.textContent = `-${formatTime(remaining)}`;
}
};
updateRemaining();
return usePlayerStore.subscribe(updateRemaining);
}, [duration]);
return <span className={className} ref={spanRef} />;
});
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false);
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
const premuteVolumeRef = useRef(1);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
@@ -81,6 +105,45 @@ export default function PlayerBar() {
setUserRatingOverride: s.setUserRatingOverride,
})));
const { lastfmSessionKey } = useAuthStore();
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
useEffect(() => {
if (!floatingPlayerBar) return;
const updatePosition = () => {
const sidebar = document.querySelector('.sidebar') as HTMLElement;
const queue = document.querySelector('.queue-panel') as HTMLElement;
const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0;
const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0;
setFloatingStyle({
left: leftOffset + 24,
right: rightOffset + 24,
width: 'auto',
});
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
const sidebar = document.querySelector('.sidebar');
const queue = document.querySelector('.queue-panel');
if (sidebar) observer.observe(sidebar);
if (queue) observer.observe(queue);
window.addEventListener('resize', updatePosition);
return () => {
observer.disconnect();
window.removeEventListener('resize', updatePosition);
};
}, [floatingPlayerBar]);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
const isRadio = !!currentRadio;
@@ -130,8 +193,14 @@ export default function PlayerBar() {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
};
return (
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
const playerBarContent = (
<>
<footer
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
role="region"
aria-label={t('player.regionLabel')}
>
{/* Track Info */}
<div className="player-track-info">
@@ -233,21 +302,32 @@ export default function PlayerBar() {
</div>
{/* Transport Controls */}
<div className="player-buttons">
<div className="player-buttons" ref={transportAnchorRef}>
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipBack size={19} />
</button>
<button
className="player-btn player-btn-primary"
onClick={togglePlay}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
<button
className="player-btn player-btn-primary"
type="button"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={10} strokeWidth={2.5} />
: <Sunrise size={10} strokeWidth={2.5} />}
<span className="player-btn-schedule-time">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
</span>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipForward size={19} />
</button>
@@ -295,7 +375,18 @@ export default function PlayerBar() {
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
<span
className="player-time player-time-toggle"
onClick={() => {
const newVal = !localShowRemaining;
setLocalShowRemaining(newVal);
useThemeStore.getState().setShowRemainingTime(newVal);
}}
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
>
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTime(duration)}
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
</span>
</>
)}
</div>
@@ -310,6 +401,16 @@ export default function PlayerBar() {
<SlidersVertical size={15} />
</button>
{/* Mini Player */}
<button
className="player-btn player-btn-sm"
onClick={() => invoke('open_mini_player').catch(() => {})}
aria-label="Mini Player"
data-tooltip="Mini Player"
>
<PictureInPicture2 size={15} />
</button>
{/* Volume */}
<div className="player-volume-section">
<button
@@ -368,5 +469,13 @@ export default function PlayerBar() {
)}
</footer>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
</>
);
if (floatingPlayerBar) {
return createPortal(playerBarContent, document.body);
}
return playerBarContent;
}
File diff suppressed because one or more lines are too long
+418 -35
View File
@@ -1,17 +1,36 @@
import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react';
import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import {
Track,
usePlayerStore,
songToTrack,
registerQueueListScrollTopReader,
consumePendingQueueListScrollTop,
} from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue';
import OrbitQueueHead from './OrbitQueueHead';
import HostApprovalQueue from './HostApprovalQueue';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { encodeSharePayload } from '../utils/shareLink';
import { copyTextToClipboard } from '../utils/serverMagicString';
import { showToast } from '../utils/toast';
import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
import OverlayScrollArea from './OverlayScrollArea';
import { useLuckyMixStore } from '../store/luckyMixStore';
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -225,8 +244,44 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
}
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
if (orbitRole === 'guest') {
return (
<aside className="queue-panel queue-panel--orbit-guest">
<OrbitGuestQueue />
</aside>
);
}
return <QueuePanelHostOrSolo />;
}
function QueuePanelHostOrSolo() {
const { t } = useTranslation();
const navigate = useNavigate();
const orbitRole = useOrbitStore(s => s.role);
const orbitState = useOrbitStore(s => s.state);
/** trackId addedBy (host username or guest username) only populated while
* hosting an Orbit session, so the queue rows can surface attribution. */
const orbitAddedByByTrack = useMemo(() => {
const map = new Map<string, string>();
if (orbitRole !== 'host' || !orbitState) return map;
if (orbitState.currentTrack) {
map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy);
}
for (const q of orbitState.queue) map.set(q.trackId, q.addedBy);
return map;
}, [orbitRole, orbitState]);
const orbitHostUsername = orbitState?.host ?? '';
/** Attribution label for a queue row / current track while hosting. Null when
* not in a hosted session. Bulk-adds (album / playlist enqueue) bypass
* `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we
* default those to "Added by you" rather than showing nothing. */
const orbitAttributionLabel = (trackId: string): string | null => {
if (orbitRole !== 'host' || !orbitState) return null;
const addedBy = orbitAddedByByTrack.get(trackId);
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
return t('orbit.queueAddedByUser', { user: addedBy });
};
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -251,7 +306,16 @@ export default function QueuePanel() {
const enqueueAt = usePlayerStore(s => s.enqueueAt);
const contextMenu = usePlayerStore(s => s.contextMenu);
// When the user picks a track *from* the queue list, suppress the
// upcoming auto-scroll so their click target stays in view instead of
// the list rebasing onto the next track. Auto-advance (natural playback)
// never sets this flag, so it keeps its original "show what's next" behavior.
const suppressNextAutoScrollRef = useRef(false);
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
@@ -261,14 +325,27 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const replayGainMode = useAuthStore(s => s.replayGainMode);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
useEffect(() => {
if (!showCrossfadePopover) return;
@@ -283,10 +360,84 @@ export default function QueuePanel() {
return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]);
useEffect(() => {
if (!lufsTgtOpen) return;
const handle = (e: MouseEvent) => {
if (
lufsTgtBtnRef.current?.contains(e.target as Node) ||
lufsTgtMenuRef.current?.contains(e.target as Node)
) return;
setLufsTgtOpen(false);
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [lufsTgtOpen]);
const updateLufsTgtPopStyle = () => {
if (!lufsTgtBtnRef.current) return;
const rect = lufsTgtBtnRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = 160;
const MAX_H = 220;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.right - WIDTH, 8),
window.innerWidth - WIDTH - 8,
);
setLufsTgtPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!lufsTgtOpen) return;
updateLufsTgtPopStyle();
}, [lufsTgtOpen]);
useEffect(() => {
if (!lufsTgtOpen) return;
const onResize = () => updateLufsTgtPopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [lufsTgtOpen]);
useEffect(() => {
if (!expandReplayGain) setLufsTgtOpen(false);
}, [expandReplayGain]);
// Tracks which queue index is being psy-dragged for opacity visual feedback
const psyDragFromIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
return () => registerQueueListScrollTopReader(null);
}, []);
useLayoutEffect(() => {
const top = consumePendingQueueListScrollTop();
if (top === undefined) return;
const el = queueListRef.current;
if (!el) return;
suppressNextAutoScrollRef.current = true;
el.scrollTop = top;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
}, [queue, queueIndex, currentTrack?.id]);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
@@ -357,13 +508,20 @@ export default function QueuePanel() {
}, [enqueueAt]);
useEffect(function queueAutoScroll() {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
return;
}
if (!queueListRef.current || queueIndex < 0) return;
if (activeTab !== 'queue') return;
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
const nextSong = songs[queueIndex + 1];
if (!nextSong) return;
nextSong.scrollIntoView({ block: "start", behavior: "instant" });
}, [currentTrack, activeTab]);
requestAnimationFrame(() => {
queueListRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
});
}, [currentTrack, activeTab]);
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
@@ -396,6 +554,18 @@ export default function QueuePanel() {
setActivePlaylist(null);
};
const handleCopyQueueShare = async () => {
if (queue.length === 0) {
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
return;
}
const srv = useAuthStore.getState().getBaseUrl();
if (!srv) return;
const ids = queue.map(t => t.id);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
};
return (
<aside
@@ -426,6 +596,12 @@ export default function QueuePanel() {
borderLeftWidth: isQueueVisible ? 1 : 0,
}}
>
{orbitRole === 'host' && orbitState && (
<>
<OrbitQueueHead state={orbitState} />
<HostApprovalQueue />
</>
)}
<QueueHeader
queue={queue}
queueIndex={queueIndex}
@@ -451,28 +627,171 @@ export default function QueuePanel() {
})(),
].filter(Boolean) as string[];
const rgParts = formatQueueReplayGainParts(currentTrack, t);
const techLine = [...baseParts, ...rgParts].join(' · ');
if (!techLine && !playbackSource) return null;
const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · ');
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
const liveGainLabel = (() => {
if (normalizationNowDb != null && Number.isFinite(normalizationNowDb)) {
return `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`;
}
if (isLoudnessActive && Number.isFinite(loudnessPreAnalysisAttenuationDb)) {
const preEff = effectiveLoudnessPreAnalysisAttenuationDb(
loudnessPreAnalysisAttenuationDb,
authLoudnessTargetLufs,
);
const ph = loudnessGainPlaceholderUntilCacheDb(
authLoudnessTargetLufs,
preEff,
);
return `${ph >= 0 ? '+' : ''}${ph.toFixed(2)} dB`;
}
return '—';
})();
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
const targetLabel = `${tgtNum} LUFS`;
if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
const showLufsLine = isLoudnessActive && expandReplayGain;
return (
<div className="queue-current-tech">
{playbackSource && (
<span
className="queue-current-tech-source"
data-tooltip={
playbackSource === 'offline'
? t('queue.sourceOffline')
: playbackSource === 'hot'
? t('queue.sourceHot')
: t('queue.sourceStream')
}
aria-hidden
>
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
</span>
)}
<span className="queue-current-tech-main">{techLine}</span>
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
<div className="queue-current-tech-stack">
<div className="queue-current-tech-row">
{playbackSource && (
<span
className="queue-current-tech-source"
data-tooltip={
playbackSource === 'offline'
? t('queue.sourceOffline')
: playbackSource === 'hot'
? t('queue.sourceHot')
: t('queue.sourceStream')
}
aria-hidden
>
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
</span>
)}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{!isLoudnessActive && rgLine && (
<button
type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
data-tooltip={`${t('queue.replayGain')} · ${rgLine}`}
aria-expanded={showRgLine}
aria-label={t('queue.replayGain')}
onClick={() => setExpandReplayGain(!expandReplayGain)}
>
RG
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
{isLoudnessActive && (
<button
type="button"
className={`queue-current-tech-rg-badge${showLufsLine ? ' queue-current-tech-rg-badge--open' : ''}`}
data-tooltip={`LUFS · ${liveGainLabel} · TGT · ${targetLabel}`}
aria-expanded={showLufsLine}
aria-label="LUFS"
onClick={() => setExpandReplayGain(!expandReplayGain)}
>
LUFS
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
</div>
{showRgLine && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span>
{' · '}{rgLine}
</span>
)}
{showLufsLine && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">Loudness</span>
{' · '}
<button
type="button"
className="queue-current-tech-metric queue-current-tech-metric--lufs-reanalyze"
onClick={e => {
e.stopPropagation();
setLufsTgtOpen(false);
void reanalyzeLoudnessForTrack(currentTrack.id);
}}
data-tooltip="Clear cached loudness and re-analyze this track"
aria-label="Clear cached loudness and re-analyze this track"
>
{liveGainLabel}
</button>
{' · '}
<span className="queue-current-tech-rg-label">TGT</span>
{' · '}
<button
type="button"
ref={lufsTgtBtnRef}
className="queue-current-tech-metric"
onClick={e => {
e.stopPropagation();
setLufsTgtOpen(v => !v);
}}
data-tooltip="Change target integrated loudness"
aria-haspopup="listbox"
aria-expanded={lufsTgtOpen}
>
{targetLabel}
</button>
{lufsTgtOpen &&
createPortal(
<div
ref={lufsTgtMenuRef}
className="queue-lufs-tgt-menu"
style={{
...lufsTgtPopStyle,
background: 'var(--bg-card)',
border: '1px solid var(--border, rgba(255,255,255,0.12))',
borderRadius: 8,
boxShadow: '0 6px 24px rgba(0,0,0,0.35)',
padding: 6,
overflow: 'auto',
}}
role="listbox"
aria-label="LUFS target"
onClick={e => e.stopPropagation()}
>
{([-10, -12, -14, -16] as const).map((v) => (
<button
key={v}
type="button"
onClick={e => {
e.stopPropagation();
if (v !== authLoudnessTargetLufs) {
setLoudnessTargetLufs(v);
}
setLufsTgtOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '6px 8px',
borderRadius: 6,
border: 'none',
background: v === authLoudnessTargetLufs ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
font: 'inherit',
}}
>
{v} LUFS
</button>
))}
</div>,
document.body,
)}
</span>
)}
</div>
</div>
);
})()}
@@ -497,6 +816,10 @@ export default function QueuePanel() {
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{(() => {
const label = orbitAttributionLabel(currentTrack.id);
return label ? <div className="queue-current-sub queue-current-attribution">{label}</div> : null;
})()}
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
</div>
</div>
@@ -520,6 +843,14 @@ export default function QueuePanel() {
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
</button>
<button
className="queue-round-btn"
onClick={() => void handleCopyQueueShare()}
data-tooltip={t('queue.shareQueue')}
aria-label={t('queue.shareQueue')}
>
<Share2 size={13} />
</button>
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
<Trash2 size={13} />
</button>
@@ -530,7 +861,7 @@ export default function QueuePanel() {
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<Infinity size={13} />
<MoveRight size={13} />
</button>
<div style={{ position: 'relative' }}>
<button
@@ -582,19 +913,27 @@ export default function QueuePanel() {
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<ArrowUpToLine size={13} />
<Infinity size={13} />
</button>
</div>
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<div className="queue-list" ref={queueListRef}>
<OverlayScrollArea
viewportRef={queueListRef}
className="queue-list-wrap"
viewportClassName="queue-list"
measureDeps={[activeTab, queue.length]}
railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag}
>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
</div>
) : (
queue.map((track, idx) => {
<>
{queue.map((track, idx) => {
const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
@@ -625,7 +964,10 @@ export default function QueuePanel() {
<div
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => playTrack(track, queue)}
onClick={() => {
suppressNextAutoScrollRef.current = true;
playTrack(track, queue);
}}
onContextMenu={(e) => {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
@@ -658,18 +1000,51 @@ export default function QueuePanel() {
<span className="truncate">{track.title}</span>
</div>
<div className="queue-item-artist truncate">{track.artist}</div>
{(() => {
const label = orbitAttributionLabel(track.id);
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
})()}
</div>
<div className="queue-item-duration">
{formatTime(track.duration)}
</div>
</div>
{luckyRolling && isPlaying && (
<button
type="button"
className="queue-lucky-loading"
onClick={() => useLuckyMixStore.getState().cancel()}
data-tooltip={t('luckyMix.cancelTooltip')}
aria-label={t('luckyMix.cancelTooltip')}
>
<div className="queue-lucky-loading__dice">
<div className="queue-lucky-cube queue-lucky-cube--a">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--tr" />
<span className="lucky-mix-pip lucky-mix-pip--bl" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--b">
<span className="lucky-mix-pip lucky-mix-pip--center" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--c">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--center" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
</div>
</button>
)}
</React.Fragment>
);
})
})}
</>
)}
</div>
</>) : (
</OverlayScrollArea>
</>) : activeTab === 'lyrics' ? (
<LyricsPane currentTrack={currentTrack} />
) : (
<NowPlayingInfo />
)}
<div className="queue-tab-bar">
@@ -689,6 +1064,14 @@ export default function QueuePanel() {
<MicVocal size={14} />
{t('player.lyrics')}
</button>
<button
className={`queue-tab-btn${activeTab === 'info' ? ' active' : ''}`}
onClick={() => setTab('info')}
aria-label={t('nowPlayingInfo.tab')}
>
<Info size={14} />
{t('nowPlayingInfo.tab')}
</button>
</div>
{saveModalOpen && (
+60
View File
@@ -0,0 +1,60 @@
import React, { useId } from 'react';
import { ChevronDown } from 'lucide-react';
interface SettingsSubSectionProps {
title: string;
icon?: React.ReactNode;
defaultOpen?: boolean;
description?: string;
searchText?: string;
// Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden
// gestoppt, damit sie das Accordion nicht togglen.
action?: React.ReactNode;
children: React.ReactNode;
}
// Wird innerhalb eines Settings-Tabs als Accordion-Gruppe genutzt. Natives
// <details> liefert Keyboard + ARIA gratis; der CSS-Stil setzt den Chevron
// im Summary mittels [open]-Selektor.
export default function SettingsSubSection({
title,
icon,
defaultOpen = false,
description,
searchText,
action,
children,
}: SettingsSubSectionProps) {
const headingId = useId();
return (
<details
className="settings-sub-section"
data-settings-search={searchText ?? title}
open={defaultOpen}
>
<summary
className="settings-sub-section-summary"
aria-labelledby={headingId}
>
{icon && <span className="settings-sub-section-icon">{icon}</span>}
<span id={headingId} className="settings-sub-section-title">{title}</span>
{action && (
<span
className="settings-sub-section-action"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{action}
</span>
)}
<ChevronDown size={16} className="settings-sub-section-chevron" aria-hidden="true" />
</summary>
{description && (
<p className="settings-sub-section-desc">{description}</p>
)}
<div className="settings-sub-section-content">
{children}
</div>
</details>
);
}
+427 -41
View File
@@ -5,19 +5,50 @@ import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Settings,
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Sparkles, Trash2,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import WhatsNewBanner from './WhatsNewBanner';
import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import OverlayScrollArea from './OverlayScrollArea';
import {
applySidebarDropReorder,
getLibraryItemsForReorder,
getSystemItemsForReorder,
isSidebarNavItemUserHideable,
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
const SMART_PREFIX = 'psy-smart-';
function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
}
function displayPlaylistName(name: string): string {
const n = name ?? '';
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
return n;
}
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
const aside = document.querySelector('aside.sidebar');
if (!aside) return false;
const r = aside.getBoundingClientRect();
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
}
export default function Sidebar({
@@ -47,7 +78,12 @@ export default function Sidebar({
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const luckyMixBase = useLuckyMixAvailable();
// Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode —
// in hub mode it lives inside the Build-a-Mix landing page instead.
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists);
@@ -66,6 +102,244 @@ export default function Sidebar({
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
const libraryTriggerPlain = filterId === 'all';
const libraryItemsForReorder = useMemo(
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
[sidebarItems, randomNavMode],
);
const systemItemsForReorder = useMemo(
() => getSystemItemsForReorder(sidebarItems),
[sidebarItems],
);
const visibleLibraryConfigs = useMemo(
() =>
libraryItemsForReorder.filter(c => {
if (!c.visible) return false;
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
}),
[libraryItemsForReorder, luckyMixAvailable],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible),
[systemItemsForReorder],
);
const [navDnd, setNavDnd] = useState<{
section: 'library' | 'system';
fromIdx: number;
} | null>(null);
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
navDropTargetRef.current = navDropTarget;
const sidebarItemsRef = useRef(sidebarItems);
sidebarItemsRef.current = sidebarItems;
const randomNavModeRef = useRef(randomNavMode);
randomNavModeRef.current = randomNavMode;
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
const longPressTimersRef = useRef<Map<number, number>>(new Map());
const suppressNavClickRef = useRef(false);
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!navDnd) return;
const updateDropFromPoint = (clientX: number, clientY: number) => {
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
navDropTargetRef.current = null;
setNavDropTarget(null);
return;
}
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
let target: SidebarNavDropTarget | null = null;
for (const row of rows) {
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
if (section !== navDnd.section) continue;
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.sidebarIdx);
if (Number.isNaN(idx)) continue;
if (clientY < rect.top + rect.height / 2) {
target = { idx, before: true, section };
break;
}
target = { idx, before: false, section };
}
navDropTargetRef.current = target;
setNavDropTarget(target);
};
const endDrag = (apply: boolean) => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
const currentDnd = navDnd;
const drop = navDropTargetRef.current;
setNavDnd(null);
setNavDropTarget(null);
navDropTargetRef.current = null;
if (!apply || !currentDnd) return;
const { x, y } = lastPointerDuringNavDndRef.current;
if (isPointerOutsideAsideSidebar(x, y)) {
const sectionItems =
currentDnd.section === 'library'
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
: getSystemItemsForReorder(sidebarItemsRef.current);
const id = sectionItems[currentDnd.fromIdx]?.id;
if (id && isSidebarNavItemUserHideable(id)) {
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
i.id === id ? { ...i, visible: false } : i,
);
setSidebarItems(nextItems);
suppressNavClickRef.current = true;
}
return;
}
const next = applySidebarDropReorder(
sidebarItemsRef.current,
currentDnd.section,
currentDnd.fromIdx,
drop,
randomNavModeRef.current,
);
if (next) {
setSidebarItems(next);
suppressNavClickRef.current = true;
}
};
const onMove = (e: PointerEvent) => {
e.preventDefault();
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
const sectionItems =
navDnd.section === 'library'
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
: getSystemItemsForReorder(sidebarItemsRef.current);
const draggedId = sectionItems[navDnd.fromIdx]?.id;
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
if (outside && canTrash) {
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
} else {
setNavDndTrashHint(null);
}
updateDropFromPoint(e.clientX, e.clientY);
};
const onUp = (e: PointerEvent) => {
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
endDrag(true);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
endDrag(false);
}
};
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
};
}, [navDnd, setSidebarItems]);
const handleNavRowPointerDown = useCallback(
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
if (isCollapsed || navDnd) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const pid = e.pointerId;
const sx = e.clientX;
const sy = e.clientY;
let cleaned = false;
const cleanupEarly = () => {
if (cleaned) return;
cleaned = true;
document.removeEventListener('pointermove', onEarlyMove);
document.removeEventListener('pointerup', onEarlyUp, true);
document.removeEventListener('pointercancel', onEarlyUp, true);
};
const onEarlyMove = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
}
};
const onEarlyUp = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
};
const timer = window.setTimeout(() => {
longPressTimersRef.current.delete(pid);
cleanupEarly();
window.getSelection()?.removeAllRanges();
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
setNavDnd({ section, fromIdx: sectionIdx });
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
setNavDropTarget({ idx: sectionIdx, before: true, section });
try {
(e.currentTarget as HTMLElement).setPointerCapture(pid);
} catch {
/* ignore */
}
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
longPressTimersRef.current.set(pid, timer);
document.addEventListener('pointermove', onEarlyMove);
document.addEventListener('pointerup', onEarlyUp, true);
document.addEventListener('pointercancel', onEarlyUp, true);
},
[isCollapsed, navDnd],
);
const navDndRowClass = useCallback(
(section: 'library' | 'system', sectionIdx: number) => {
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
let drop = '';
if (
navDnd &&
navDropTarget?.section === section &&
navDropTarget.idx === sectionIdx &&
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
) {
drop = navDropTarget.before
? 'sidebar-nav-dnd-row--drop-before'
: 'sidebar-nav-dnd-row--drop-after';
}
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
},
[navDnd, navDropTarget],
);
const updateDropdownPosition = useCallback(() => {
const el = libraryTriggerRef.current;
if (!el) return;
@@ -120,22 +394,13 @@ export default function Sidebar({
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => {
if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false;
// Hide mode-inactive mix entries so the active mode controls what's shown
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
const visibleSystem = sidebarItems
.filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
useEffect(() => () => {
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
longPressTimersRef.current.clear();
}, []);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
{isCollapsed
@@ -153,7 +418,35 @@ export default function Sidebar({
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav className="sidebar-nav" aria-label="Main navigation">
<nav
className="sidebar-nav"
aria-label="Main navigation"
onClickCapture={e => {
if (suppressNavClickRef.current) {
suppressNavClickRef.current = false;
e.preventDefault();
e.stopPropagation();
}
}}
>
<OverlayScrollArea
className="sidebar-nav-scroll"
viewportClassName="sidebar-nav-viewport"
railInset="panel"
measureDeps={[
isCollapsed,
playlistsExpanded,
playlists.length,
isLoggedIn,
randomNavMode,
filterId,
hasOfflineContent,
activeJobs.length,
isSyncing,
syncJobTotal,
sidebarItems.length,
]}
>
{!isCollapsed && (showLibraryPicker ? (
<>
<button
@@ -228,10 +521,29 @@ export default function Sidebar({
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
item.to === '/playlists' ? (
// Playlists item with expand button
<div key={item.to} className="sidebar-playlists-wrapper">
{visibleLibraryConfigs.map(cfg => {
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return null;
const sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
const dndRow = !isCollapsed && sectionIdx >= 0;
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
const dndProps = dndRow
? {
'data-sidebar-nav-dnd-row': '',
'data-sidebar-section': 'library' as const,
'data-sidebar-idx': String(sectionIdx),
onPointerDown: (e: React.PointerEvent) =>
handleNavRowPointerDown(e, 'library', sectionIdx),
}
: {};
return item.to === '/playlists' ? (
<div
key={item.to}
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<div className="sidebar-playlists-header-row">
<NavLink
to={item.to}
@@ -268,18 +580,18 @@ export default function Sidebar({
key={pl.id}
to={`/playlists/${pl.id}`}
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? pl.name : undefined}
data-tooltip={isCollapsed ? displayPlaylistName(pl.name) : undefined}
data-tooltip-pos="bottom"
>
<PlayCircle size={12} />
<span>{pl.name}</span>
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
<span>{displayPlaylistName(pl.name)}</span>
</NavLink>
))
)}
</div>
)}
</div>
) : (
) : isCollapsed ? (
<NavLink
key={item.to}
to={item.to}
@@ -291,8 +603,32 @@ export default function Sidebar({
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
)
))}
) : (
<div
key={item.to}
className={rowClass}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<NavLink
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
</div>
);
})}
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
<div className="sidebar-bottom-spacer" />
{/* What's New banner — only visible while the current release hasn't been seen. */}
<WhatsNewBanner collapsed={isCollapsed} />
{/* Now Playing — fixed, always visible */}
<NavLink
@@ -300,7 +636,6 @@ export default function Sidebar({
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip-pos="bottom"
style={{ marginTop: 'auto' }}
>
<span className="nav-np-icon-wrap">
<AudioLines size={isCollapsed ? 22 : 18} />
@@ -321,19 +656,53 @@ export default function Sidebar({
</NavLink>
)}
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystem.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
))}
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystemConfigs.map(cfg => {
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return null;
const sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
const dndRow = !isCollapsed && sectionIdx >= 0;
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
const dndProps = dndRow
? {
'data-sidebar-nav-dnd-row': '',
'data-sidebar-section': 'system' as const,
'data-sidebar-idx': String(sectionIdx),
onPointerDown: (e: React.PointerEvent) =>
handleNavRowPointerDown(e, 'system', sectionIdx),
}
: {};
return isCollapsed ? (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
) : (
<div
key={item.to}
className={rowClass}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<NavLink
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
</div>
);
})}
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
@@ -378,7 +747,24 @@ export default function Sidebar({
)}
</div>
)}
</OverlayScrollArea>
</nav>
</aside>
{navDndTrashHint != null &&
createPortal(
<div
className="sidebar-nav-dnd-trash-hint"
style={{
position: 'fixed',
left: navDndTrashHint.x + 14,
top: navDndTrashHint.y + 14,
}}
aria-hidden
>
<Trash2 size={22} strokeWidth={2.25} />
</div>,
document.body,
)}
</>
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import CachedImage from './CachedImage';
import { enqueueAndPlay } from '../utils/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
interface SongCardProps {
song: SubsonicSong;
}
function SongCard({ song }: SongCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : '';
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueueAndPlay(song);
};
const handleEnqueue = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueue([songToTrack(song)]);
};
const handleClick = handlePlay;
const handleArtistClick = (e: React.MouseEvent) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
};
return (
<div
className="song-card card"
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${song.title} ${song.artist}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
onMouseDown={e => {
if (e.button !== 0) return;
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', id: song.id, name: song.title }), label: song.title, 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="song-card-cover">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
alt={`${song.album} Cover`}
loading="lazy"
/>
) : (
<div className="song-card-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)}
<div className="song-card-play-overlay">
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handlePlay(); }}
aria-label={t('tracks.playSong')}
data-tooltip={t('tracks.playSong')}
data-tooltip-pos="top"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handleEnqueue(); }}
aria-label={t('tracks.enqueueSong')}
data-tooltip={t('tracks.enqueueSong')}
data-tooltip-pos="top"
>
<ListPlus size={14} />
</button>
</div>
</div>
<div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={handleArtistClick}
title={song.artist}
>{song.artist}</p>
</div>
</div>
);
}
export default memo(SongCard);
+26 -3
View File
@@ -5,6 +5,8 @@ import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { getSong, SubsonicSong } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../utils/serverMagicString';
import { showToast } from '../utils/toast';
function formatDuration(s: number): string {
const m = Math.floor(s / 60);
@@ -28,6 +30,27 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
);
}
/** Title / Artist / Album: double-click the value cell to copy plain text. */
function CopyableFieldRow({ label, text }: { label: string; text: string | null | undefined }) {
const { t } = useTranslation();
if (!text || text === '—') return null;
const onDoubleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const ok = await copyTextToClipboard(text);
if (ok) showToast(t('orbit.tooltipCopied'), 2000, 'info');
else showToast(t('contextMenu.shareCopyFailed'), 3500, 'error');
};
return (
<tr>
<td className="song-info-label">{label}</td>
<td className="song-info-value song-info-value--no-select" onDoubleClick={onDoubleClick}>
{text}
</td>
</tr>
);
}
function Divider() {
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
}
@@ -96,9 +119,9 @@ export default function SongInfoModal() {
{!loading && song && (
<table className="song-info-table">
<tbody>
<Row label={t('songInfo.songTitle')} value={song.title} />
<Row label={t('songInfo.artist')} value={song.artist} />
<Row label={t('songInfo.album')} value={song.album} />
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
+91
View File
@@ -0,0 +1,91 @@
import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import SongCard from './SongCard';
interface Props {
title: string;
songs: SubsonicSong[];
/** Called when user clicks the reroll button (visible only if provided). */
onReroll?: () => void | Promise<void>;
/** Loading state — disables reroll, optional shimmer */
loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */
emptyText?: string;
}
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [songs]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
// Hide rail entirely if empty and no empty-state copy
if (songs.length === 0 && !loading && !emptyText) return null;
return (
<section className="song-row-section">
<div className="song-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="song-row-nav">
{onReroll && (
<button
className="nav-btn song-row-reroll"
onClick={() => onReroll()}
disabled={loading}
aria-label="Reroll"
data-tooltip="Reroll"
data-tooltip-pos="top"
>
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button>
)}
<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}
>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="song-grid-wrapper">
{songs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{songs.map(s => (
<SongCard key={s.id} song={s} />
))}
</div>
)}
</div>
</section>
);
}
+130
View File
@@ -0,0 +1,130 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SubsonicSong } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { enqueueAndPlay } from '../utils/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
function fmtDuration(s: number): string {
if (!s || !isFinite(s)) return '';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
interface Props {
song: SubsonicSong;
}
function SongRow({ song }: Props) {
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id);
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
// In an orbit session both buttons collapse into the orbit-suggest / host-enqueue
// path so we don't ship a queue replacement to every guest.
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueueAndPlay(song);
};
const handleEnqueue = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueue([songToTrack(song)]);
};
return (
<div
className={`song-list-row${isCurrent ? ' is-current' : ''}`}
onDoubleClick={handlePlay}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
const sx = e.clientX, sy = e.clientY;
const track = songToTrack(song);
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 }), 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);
}}
>
<div className="song-list-row-cell song-list-row-actions">
<button
className="song-list-row-btn song-list-row-btn--play"
onClick={(e) => { e.stopPropagation(); handlePlay(); }}
aria-label="Play"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-list-row-btn"
onClick={(e) => { e.stopPropagation(); handleEnqueue(); }}
aria-label="Enqueue"
>
<ListPlus size={14} />
</button>
</div>
<div className="song-list-row-cell song-list-row-title truncate" title={song.title}>{song.title}</div>
<div className="song-list-row-cell truncate">
<span
className={song.artistId ? 'track-artist-link' : ''}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
title={song.artist}
>{song.artist}</span>
</div>
<div className="song-list-row-cell truncate">
{song.albumId ? (
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
title={song.album}
>{song.album}</span>
) : <span title={song.album}>{song.album}</span>}
</div>
<div className="song-list-row-cell song-list-row-genre truncate" title={song.genre ?? ''}>
{song.genre ?? '—'}
</div>
<div className="song-list-row-cell song-list-row-duration">{fmtDuration(song.duration)}</div>
</div>
);
}
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
export function SongListHeader() {
const { t } = useTranslation();
return (
<div className="song-list-row song-list-row--header" role="row">
<div className="song-list-row-cell song-list-row-actions" />
<div className="song-list-row-cell">{t('albumDetail.trackTitle')}</div>
<div className="song-list-row-cell">{t('albumDetail.trackArtist')}</div>
<div className="song-list-row-cell">{t('albumDetail.trackAlbum')}</div>
<div className="song-list-row-cell">{t('randomMix.trackGenre')}</div>
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
</div>
);
}
export default memo(SongRow);
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowDownUp, Check } from 'lucide-react';
export interface SortOption<V extends string> {
value: V;
label: string;
}
interface Props<V extends string> {
value: V;
options: SortOption<V>[];
onChange: (value: V) => void;
ariaLabel?: string;
}
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel }: Props<V>) {
const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const current = options.find(o => o.value === value);
const updatePopStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = Math.max(rect.width, 220);
const MAX_H = 300;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left, 8),
window.innerWidth - WIDTH - 8,
);
setPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!open) return;
updatePopStyle();
}, [open]);
useEffect(() => {
if (!open) return;
const onResize = () => updatePopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!popRef.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="btn btn-surface"
onClick={() => setOpen(v => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<ArrowDownUp size={14} />
{current?.label ?? value}
</button>
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="listbox"
>
<div className="genre-filter-popover__list">
{options.map(opt => {
const isSel = opt.value === value;
return (
<div
key={opt.value}
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
onClick={() => { onChange(opt.value); setOpen(false); }}
role="option"
aria-selected={isSel}
>
<span className="genre-filter-popover__check">
{isSel && <Check size={12} strokeWidth={3} />}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{opt.label}
</span>
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
}
+6 -12
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { X, Minus, Square } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
export default function TitleBar() {
@@ -10,8 +9,6 @@ export default function TitleBar() {
return (
<div className="titlebar" data-tauri-drag-region>
<span className="titlebar-title" data-tauri-drag-region>Psysonic</span>
<div className="titlebar-track" data-tauri-drag-region>
{currentTrack && (
<>
@@ -29,25 +26,22 @@ export default function TitleBar() {
onClick={() => win.minimize()}
data-tooltip="Minimize"
data-tooltip-pos="bottom"
>
<Minus size={10} />
</button>
aria-label="Minimize"
/>
<button
className="titlebar-btn titlebar-btn-maximize"
onClick={() => win.toggleMaximize()}
data-tooltip="Maximize"
data-tooltip-pos="bottom"
>
<Square size={9} />
</button>
aria-label="Maximize"
/>
<button
className="titlebar-btn titlebar-btn-close"
onClick={() => win.close()}
data-tooltip="Close"
data-tooltip-pos="bottom"
>
<X size={12} />
</button>
aria-label="Close"
/>
</div>
</div>
);

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