Compare commits

...

220 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
180 changed files with 32397 additions and 3514 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 -312
View File
@@ -1,317 +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: re-sign updater bundle + upload .sig (macOS only)
# tauri-action re-packs the .app into .app.tar.gz after tauri CLI is
# done, which invalidates the .sig tauri CLI created (different hash).
# We can't stop the repack (it's tied to includeUpdaterJson), so we
# sign the final repacked .tar.gz ourselves and upload the fresh .sig.
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
VERSION=${{ needs.create-release.outputs.package_version }}
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 "app-v${VERSION}" \
"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
- 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
pull-requests: 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: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
# The cachix-action daemon writes a post-build-hook into the user
# nix.conf, but the Determinate Nix daemon that runs the builds reads
# the system nix.conf — so the hook never fires and only a couple of
# early prep paths get uploaded. Force an explicit closure push here;
# cachix dedupes against anything already in the cache.
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-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 main \
--head "$BRANCH" \
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
--body "Auto-generated after the v${VERSION} release: refreshes \`flake.lock\` and \`nix/upstream-sources.json\` so the Cachix substituter resolves the latest pin."
gh pr merge "$BRANCH" --squash --auto --delete-branch
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}).`);
+431
View File
@@ -13,6 +13,437 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
>
> **📦 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.44.0] - 2026-04-29
## 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
+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 -121
View File
@@ -1,165 +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://psysonic.cachix.org"><img alt="Cachix" src="https://img.shields.io/badge/Cachix-psysonic-5277c3?style=flat-square&logo=nixos&logoColor=white"></a>
<a href="https://discord.gg/AMnDRErm4u"><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, NixOS flake).
- ❄️ **NixOS / flakes**: First-class flake package with a public **Cachix** binary cache (`psysonic.cachix.org`) — `nix run github:Psychotoxical/psysonic` or add to your system config. See the [NixOS install guide](./nixos-install.md).
> [!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
- **Any distro (portable)**: `.AppImage` from GitHub Releases — `chmod +x` and run, no install required
## Windows
**Arch Linux (AUR):**
Download the latest installer from Releases.
| 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. |
> SmartScreen warnings may appear until the code-signing certificate is active.
> [!TIP]
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
## macOS
**❄️ NixOS (flakes):**
- `nix run github:Psychotoxical/psysonic` — one-shot launch
- Full guide: [`nixos-install.md`](./nixos-install.md) *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#209](https://github.com/Psychotoxical/psysonic/pull/209))*
Download the signed DMG from Releases.
### 🍎 macOS
---
- **macOS**: `.dmg` (Universal or Apple Silicon) — **signed with an Apple Developer ID and notarized by Apple**. Gatekeeper opens it with a single click, no `xattr` workaround required.
> [!NOTE]
> Since **v1.40.0**, macOS builds include an in-app auto-updater: click **Install now** in the update notification and the signed `.app.tar.gz` is fetched, verified against the bundled minisign public key, replaced in place, and the app relaunches — all in one step.
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 🚀 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-5YAQ2PqxJtD7+adecF++swOHVKNstEkyRQeiBrP2lvA="
"npmDepsHash": "sha256-PYqvngGz/yreDiQxAEIhD5nuEPezAELj/zaEpJ7c1f4="
}
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.42.1",
"version": "1.44.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.42.1",
"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.43.0",
"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.42.1
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}`);
+78 -3
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"
@@ -3653,10 +3706,11 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.43.0"
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",
@@ -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"
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.43.0"
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"
@@ -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)?;
}
}
+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(())
}
+1229 -179
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(())
+1328 -83
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);
}
}};
}
+7
View File
@@ -85,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;
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.43.0",
"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": {
+209 -79
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,40 +24,56 @@ 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';
import WhatsNew from './pages/WhatsNew';
// 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 AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
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';
@@ -75,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';
@@ -84,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;
@@ -94,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(() => {});
@@ -147,6 +207,7 @@ 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);
@@ -176,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;
@@ -200,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
@@ -290,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();
@@ -324,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);
@@ -334,16 +410,15 @@ 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 window is minimized / hidden.
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
// components.css pauses all running animations while this flag is set.
// 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';
@@ -383,6 +458,7 @@ function AppShell() {
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<NowPlayingDropdown />
<OrbitStartTrigger />
<button
className="queue-toggle-btn"
onClick={toggleQueue}
@@ -392,39 +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="/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>
<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>
@@ -433,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' }}
@@ -447,6 +538,9 @@ function AppShell() {
<ContextMenu />
<SongInfoModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<OrbitAccountPicker />
<OrbitHelpModal />
<TooltipPortal />
<AppUpdater />
</div>
@@ -770,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) {
@@ -882,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();
@@ -1014,6 +1140,7 @@ export default function App() {
return (
<DragDropProvider>
<MiniPlayer />
<GlobalConfirmModal />
<TooltipPortal />
</DragDropProvider>
);
@@ -1084,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;
}
}
+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);
}
+85 -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() {
@@ -80,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 {
@@ -120,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 {
@@ -287,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,
});
@@ -299,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 };
}
}
@@ -489,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 });
@@ -526,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[],
@@ -536,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);
}
@@ -549,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 */
@@ -572,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);
}
@@ -585,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 */
@@ -892,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 });
}
@@ -944,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()}`;
}
@@ -964,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()}`;
}
@@ -978,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}
+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);
+19 -10
View File
@@ -7,10 +7,15 @@ interface ConfirmModalProps {
title: string;
message: string;
confirmLabel: string;
cancelLabel: 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;
onCancel?: () => void;
}
export default function ConfirmModal({
@@ -23,15 +28,17 @@ export default function ConfirmModal({
onConfirm,
onCancel,
}: ConfirmModalProps) {
const dismiss = onCancel ?? onConfirm;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
if (e.key === 'Escape') dismiss();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onCancel, onConfirm]);
}, [open, dismiss, onConfirm]);
if (!open) return null;
@@ -42,7 +49,7 @@ export default function ConfirmModal({
return createPortal(
<div
className="modal-overlay"
onClick={onCancel}
onClick={dismiss}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
@@ -52,7 +59,7 @@ export default function ConfirmModal({
onClick={e => e.stopPropagation()}
style={{ maxWidth: '380px' }}
>
<button className="modal-close" onClick={onCancel} aria-label={cancelLabel}>
<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>
@@ -60,10 +67,12 @@ export default function ConfirmModal({
{message}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
{cancelLabel}
</button>
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm}>
{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>
+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>
+76 -11
View File
@@ -1,12 +1,13 @@
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';
@@ -14,6 +15,10 @@ import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
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';
@@ -428,6 +433,28 @@ 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(() => {
const s = usePlayerStore.getState();
@@ -438,6 +465,7 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
if (inputRef.current) inputRef.current.value = String(s.progress);
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}%`;
@@ -447,8 +475,10 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
}, []);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
[seek]
(e: React.ChangeEvent<HTMLInputElement>) => {
previewSeek(parseFloat(e.target.value));
},
[previewSeek]
);
return (
@@ -466,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>
@@ -549,14 +587,40 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, tr
});
// ─── 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} />
</>
);
});
@@ -676,13 +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);
@@ -789,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>
+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>
+111 -56
View File
@@ -1,15 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
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, ArrowUpToLine } from 'lucide-react';
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 };
@@ -107,12 +111,16 @@ export default function MiniPlayer() {
});
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
const volumeWrapRef = 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
@@ -137,25 +145,6 @@ export default function MiniPlayer() {
// ── Context menu state ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
// Compute overlay-scrollbar thumb height + offset from the queue's scroll
// metrics. Native scrollbar is hidden via CSS; this thumb floats over the
// items so the queue keeps its full width.
const recomputeScroll = useCallback(() => {
const el = queueScrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollHeight <= clientHeight + 1) {
setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev));
return;
}
const ratio = clientHeight / scrollHeight;
const thumbH = Math.max(24, Math.round(ratio * clientHeight));
const range = clientHeight - thumbH;
const scrollRange = scrollHeight - clientHeight;
const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0;
setScrollMeta({ thumbH, thumbT, visible: true });
}, []);
// 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
@@ -169,6 +158,21 @@ export default function MiniPlayer() {
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
@@ -217,6 +221,16 @@ export default function MiniPlayer() {
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(() => {});
@@ -238,6 +252,7 @@ export default function MiniPlayer() {
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);
});
@@ -262,13 +277,58 @@ export default function MiniPlayer() {
handleVolumeChange(volume === 0 ? 1 : 0);
};
// Close the volume popover on outside click / Escape.
// 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) => {
if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) {
setVolumeOpen(false);
}
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);
@@ -360,17 +420,11 @@ export default function MiniPlayer() {
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]);
// Recompute overlay-thumb on open, queue mutations, and window resize.
useEffect(() => {
if (!queueOpen) return;
recomputeScroll();
const onResize = () => recomputeScroll();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [queueOpen, state.queue.length, recomputeScroll]);
const { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
@@ -457,8 +511,9 @@ export default function MiniPlayer() {
</div>
<div className="mini-player__toolbar" data-tauri-drag-region="false">
<div className="mini-player__volume-wrap" ref={volumeWrapRef}>
<div className="mini-player__volume-wrap">
<button
ref={volumeBtnRef}
type="button"
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => setVolumeOpen(v => !v)}
@@ -469,8 +524,13 @@ export default function MiniPlayer() {
>
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button>
{volumeOpen && (
<div className="mini-player__volume-popover" data-tauri-drag-region="false">
{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"
@@ -505,7 +565,8 @@ export default function MiniPlayer() {
style={{ height: `${Math.round(volume * 100)}%` }}
/>
</div>
</div>
</div>,
document.body,
)}
</div>
@@ -531,7 +592,7 @@ export default function MiniPlayer() {
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<InfinityIcon size={13} />
<MoveRight size={13} />
</button>
<button
@@ -553,7 +614,7 @@ export default function MiniPlayer() {
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<ArrowUpToLine size={13} />
<InfinityIcon size={13} />
</button>
<span className="mini-player__toolbar-sep" aria-hidden />
@@ -571,8 +632,13 @@ export default function MiniPlayer() {
</div>
{queueOpen && (
<div
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
<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]');
@@ -591,7 +657,6 @@ export default function MiniPlayer() {
setDropTarget(null);
}}
>
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
{state.queue.length === 0 ? (
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : (
@@ -651,17 +716,7 @@ export default function MiniPlayer() {
);
})
)}
</div>
{scrollMeta.visible && (
<div
className="mini-queue__thumb"
style={{
height: `${scrollMeta.thumbH}px`,
transform: `translateY(${scrollMeta.thumbT}px)`,
}}
/>
)}
</div>
</OverlayScrollArea>
)}
<div className="mini-player__bottom" data-tauri-drag-region="false">
+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,
);
}
+33 -10
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
PictureInPicture2, ArrowLeftRight,
PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
@@ -21,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';
@@ -136,6 +140,11 @@ export default function PlayerBar() {
};
}, [floatingPlayerBar]);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
const isRadio = !!currentRadio;
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
@@ -185,6 +194,7 @@ export default function PlayerBar() {
};
const playerBarContent = (
<>
<footer
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
@@ -292,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>
@@ -448,6 +469,8 @@ export default function PlayerBar() {
)}
</footer>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
</>
);
if (floatingPlayerBar) {
File diff suppressed because one or more lines are too long
+371 -16
View File
@@ -1,18 +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, ChevronDown } 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';
@@ -226,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);
@@ -252,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);
@@ -262,16 +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;
@@ -286,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();
@@ -360,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');
@@ -399,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
@@ -429,6 +596,12 @@ export default function QueuePanel() {
borderLeftWidth: isQueueVisible ? 1 : 0,
}}
>
{orbitRole === 'host' && orbitState && (
<>
<OrbitQueueHead state={orbitState} />
<HostApprovalQueue />
</>
)}
<QueueHeader
queue={queue}
queueIndex={queueIndex}
@@ -456,8 +629,29 @@ export default function QueuePanel() {
const rgParts = formatQueueReplayGainParts(currentTrack, t);
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 = expandReplayGain && !!rgLine;
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
const showLufsLine = isLoudnessActive && expandReplayGain;
return (
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
<div className="queue-current-tech-stack">
@@ -480,7 +674,7 @@ export default function QueuePanel() {
</span>
)}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{rgLine && (
{!isLoudnessActive && rgLine && (
<button
type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
@@ -493,6 +687,19 @@ export default function QueuePanel() {
<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">
@@ -500,6 +707,90 @@ export default function QueuePanel() {
{' · '}{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>
);
@@ -525,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>
@@ -548,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>
@@ -558,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
@@ -610,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);
@@ -653,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);
@@ -686,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">
@@ -717,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>
);
}
+420 -40
View File
@@ -5,13 +5,13 @@ 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';
@@ -19,6 +19,36 @@ 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({
@@ -48,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);
@@ -67,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;
@@ -121,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
@@ -154,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
@@ -229,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}
@@ -269,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}
@@ -292,8 +603,26 @@ 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" />
@@ -327,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' : ''}`}
@@ -384,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);
+6 -10
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() {
@@ -27,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>
);
+220
View File
@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SubsonicSong, searchSongsPaged } from '../api/subsonic';
import { ndListSongs } from '../api/navidromeBrowse';
import SongRow, { SongListHeader } from './SongRow';
const PAGE_SIZE = 50;
const SEARCH_DEBOUNCE_MS = 300;
const ROW_HEIGHT = 52;
const PREFETCH_PX = 600;
/**
* Empty query Navidrome /api/song sorted by title (no Subsonic equivalent).
* Non-empty Subsonic search3 (search isn't a browse).
* Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3.
*/
async function fetchSongPage(query: string, offset: number): Promise<SubsonicSong[]> {
if (query !== '') {
return searchSongsPaged(query, PAGE_SIZE, offset);
}
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
return searchSongsPaged('', PAGE_SIZE, offset);
}
}
interface Props {
title?: string;
emptyBrowseText?: string;
}
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [browseUnsupported, setBrowseUnsupported] = useState(false);
const scrollParentRef = useRef<HTMLDivElement>(null);
const requestSeqRef = useRef(0);
// Debounce query
useEffect(() => {
const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(h);
}, [query]);
// Reset + first-page fetch on query change. One effect, no dep cascade,
// and a `cancelled` flag so a fast typist doesn't see results from stale queries.
useEffect(() => {
let cancelled = false;
setSongs([]);
setOffset(0);
setHasMore(true);
setBrowseUnsupported(false);
if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0;
const seq = ++requestSeqRef.current;
setLoading(true);
(async () => {
try {
const page = await fetchSongPage(debouncedQuery, 0);
if (cancelled || seq !== requestSeqRef.current) return;
if (page.length === 0) {
setHasMore(false);
if (debouncedQuery === '') setBrowseUnsupported(true);
} else {
setSongs(page);
setOffset(page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
if (!cancelled) setHasMore(false);
} finally {
if (!cancelled && seq === requestSeqRef.current) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [debouncedQuery]);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const seq = ++requestSeqRef.current;
try {
const page = await fetchSongPage(debouncedQuery, offset);
if (seq !== requestSeqRef.current) return;
if (page.length === 0) {
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
return merged;
});
setOffset(o => o + page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
setHasMore(false);
} finally {
if (seq === requestSeqRef.current) setLoading(false);
}
}, [loading, hasMore, debouncedQuery, offset]);
// Scroll-based prefetch — uses ref so a stale loadMore can't loop
const loadMoreRef = useRef(loadMore);
useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]);
useEffect(() => {
const el = scrollParentRef.current;
if (!el) return;
let rafId = 0;
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) {
loadMoreRef.current();
}
});
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
const virtualizer = useVirtualizer({
count: songs.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const totalSize = virtualizer.getTotalSize();
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
return (
<section className="virtual-song-list-section">
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
<div className="virtual-song-list-toolbar">
<div className="virtual-song-list-search">
<SearchIcon size={16} className="virtual-song-list-search-icon" />
<input
type="text"
className="input virtual-song-list-search-input"
placeholder={t('tracks.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
{query && (
<button
className="virtual-song-list-search-clear"
onClick={() => setQuery('')}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
)}
</div>
<div className="virtual-song-list-meta">
{songs.length > 0 && (
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
)}
</div>
</div>
{showEmptyBrowse ? (
<div className="virtual-song-list-empty">
{emptyBrowseText ?? t('tracks.browseUnsupported')}
</div>
) : (
<>
<SongListHeader />
<div ref={scrollParentRef} className="virtual-song-list-scroll">
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const song = songs[vi.index];
if (!song) return null;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${vi.start}px)`,
}}
>
<SongRow
song={song}
/>
</div>
);
})}
</div>
{loading && (
<div className="virtual-song-list-loading">
<div className="spinner" style={{ width: 18, height: 18 }} />
<span>{t('common.loadingMore')}</span>
</div>
)}
</div>
</>
)}
</section>
);
}
+237 -33
View File
@@ -7,7 +7,14 @@ function fmt(s: number): string {
}
const BAR_COUNT = 500;
/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */
const WAVE_BIN_COUNT = 500;
/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */
const WAVE_MIX_MEAN = 0.7;
const WAVE_MIX_MAX = 0.3;
const SEG_COUNT = 60;
const FLAT_WAVE_NORM = 0.06;
const WAVE_MORPH_MS = 1000;
// ── animation state ───────────────────────────────────────────────────────────
@@ -96,6 +103,59 @@ export function makeHeights(trackId: string): Float32Array {
// ── draw functions ────────────────────────────────────────────────────────────
function makeFlatWaveHeights(): Float32Array {
const h = new Float32Array(BAR_COUNT);
h.fill(FLAT_WAVE_NORM);
return h;
}
function easeOutCubic(t: number): number {
const x = Math.max(0, Math.min(1, t));
return 1 - Math.pow(1 - x, 3);
}
function binsToHeights(src: number[]): Float32Array {
const h = new Float32Array(BAR_COUNT);
const n = src.length;
if (n === WAVE_BIN_COUNT * 2) {
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
const maxNorm = Number(src[idx]) / 255;
const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255;
const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm;
h[i] = Math.max(0.08, Math.min(1, v));
}
return h;
}
if (n === WAVE_BIN_COUNT) {
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
return h;
}
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
return h;
}
function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > eps) return false;
}
return true;
}
function waveformBarThickness(logicalH: number, norm: number): number {
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
return Math.max(1, safeNorm * logicalH);
}
function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
@@ -108,9 +168,38 @@ function drawWaveform(
const { played, buffered: buffCol, unplayed } = getColors();
if (!heights) {
ctx.globalAlpha = 0.3;
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
// before played/buffered — otherwise there is no visible playhead.
const cy = h / 2;
const lh = 2;
const dotR = 5;
ctx.globalAlpha = 0.35;
ctx.fillStyle = unplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2);
ctx.fillRect(0, cy - lh / 2, w, lh);
if (buffered > 0) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh);
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
if (w > 0) {
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
return;
}
@@ -122,7 +211,7 @@ function drawWaveform(
ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue;
const bh = Math.max(1, heights[i] * h);
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
@@ -132,7 +221,7 @@ function drawWaveform(
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue;
const bh = Math.max(1, heights[i] * h);
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
@@ -140,29 +229,14 @@ function drawWaveform(
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
const bh = Math.max(1, heights[i] * h);
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
// Fade both edges to transparent using destination-in gradient mask
const fadeW = Math.min(22, w * 0.07);
const mask = ctx.createLinearGradient(0, 0, w, 0);
mask.addColorStop(0, 'transparent');
mask.addColorStop(fadeW / w, 'black');
mask.addColorStop(1 - fadeW / w, 'black');
mask.addColorStop(1, 'transparent');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = mask;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
}
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
@@ -700,7 +774,8 @@ export function drawSeekbar(
) {
const anim = animState ?? makeAnimState();
switch (style) {
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
case 'pseudowave': drawWaveform(canvas, heights, progress, buffered); break;
case 'linedot': drawLineDot(canvas, progress, buffered); break;
case 'bar': drawBar(canvas, progress, buffered); break;
case 'thick': drawThick(canvas, progress, buffered); break;
@@ -727,27 +802,45 @@ export function SeekbarPreview({
onClick: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let heights: Float32Array | null = null;
if (style === 'waveform') {
if (style === 'truewave' || style === 'pseudowave') {
heights = makeHeights('seekbar-preview-demo');
}
const animState = makeAnimState();
let t = 0;
let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
t += 0.016;
animState.time = t;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered, animState);
rafRef.current = requestAnimationFrame(tick);
rafId = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
tick();
return () => stop();
}, [style]);
return (
@@ -801,6 +894,9 @@ interface Props {
}
export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_GUARD_MS = 900;
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress);
@@ -811,6 +907,7 @@ export default function WaveformSeek({ trackId }: Props) {
const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek);
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -824,8 +921,81 @@ export default function WaveformSeek({ trackId }: Props) {
heightsRef.current = null;
return;
}
heightsRef.current = makeHeights(trackId);
}, [trackId]);
// Pseudowave is the deterministic per-track-ID variant — no analysis needed,
// no morph animation, no flat-fallback. It just sits there looking like a
// waveform.
if (seekbarStyle === 'pseudowave') {
heightsRef.current = makeHeights(trackId);
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(seekbarStyle)) {
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
}
return;
}
if (waveformBins && waveformBins.length > 0) {
const h = binsToHeights(waveformBins);
const prev = heightsRef.current;
if (!prev || prev.length !== BAR_COUNT) {
heightsRef.current = h;
return;
}
if (heightsNearlyEqual(prev, h, 0.02)) {
heightsRef.current = h;
return;
}
const from = new Float32Array(prev);
const to = h;
const startedAt = performance.now();
let raf = 0;
const step = (now: number) => {
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
const next = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
next[i] = from[i] + (to[i] - from[i]) * p;
}
heightsRef.current = next;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
}
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}
if (heightsRef.current?.length === BAR_COUNT) {
const current = heightsRef.current;
let isAlreadyFlat = true;
for (let i = 0; i < BAR_COUNT; i++) {
if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) {
isAlreadyFlat = false;
break;
}
}
if (isAlreadyFlat) return;
const from = new Float32Array(current);
const to = makeFlatWaveHeights();
const startedAt = performance.now();
let raf = 0;
const step = (now: number) => {
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
const next = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
next[i] = from[i] + (to[i] - from[i]) * p;
}
heightsRef.current = next;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
}
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}
// No analysis bins yet: render 500 flat bars immediately.
heightsRef.current = makeFlatWaveHeights();
}, [trackId, waveformBins, seekbarStyle]);
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
@@ -835,6 +1005,15 @@ export default function WaveformSeek({ trackId }: Props) {
// While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker.
if (isDragging.current) return;
const pendingCommit = pendingCommittedSeekRef.current;
if (pendingCommit) {
const ageMs = Date.now() - pendingCommit.setAtMs;
if (ageMs < SEEK_COMMIT_MIN_HOLD_MS) return;
const matched = Math.abs(state.progress - pendingCommit.fraction) <= SEEK_COMMIT_PROGRESS_EPS;
const expired = ageMs > SEEK_COMMIT_GUARD_MS;
if (!matched && !expired) return;
pendingCommittedSeekRef.current = null;
}
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
if (!ANIMATED_STYLES.has(styleRef.current)) {
@@ -844,12 +1023,17 @@ export default function WaveformSeek({ trackId }: Props) {
});
}, []);
// Initial draw for static styles when style or track changes.
// Initial draw for static styles when style, track, or waveform payload changes.
useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return;
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
}, [seekbarStyle, trackId]);
}, [
seekbarStyle,
trackId,
waveformBins,
duration,
]);
// rAF loop — animated styles only.
useEffect(() => {
@@ -857,14 +1041,32 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
animStateRef.current = makeAnimState();
let rafId: number;
let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
tick();
return () => stop();
}, [seekbarStyle]);
// Resize observer.
@@ -894,6 +1096,7 @@ export default function WaveformSeek({ trackId }: Props) {
const seekRef = useRef(seek);
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
// Preview a 01 fraction while dragging: draw immediately for 1:1
// responsiveness; the actual seek is committed on mouseup.
@@ -910,6 +1113,7 @@ export default function WaveformSeek({ trackId }: Props) {
const fraction = pendingSeekRef.current;
if (fraction === null) return;
pendingSeekRef.current = null;
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
seekRef.current(fraction);
};
+4 -1
View File
@@ -2,7 +2,8 @@ import React from 'react';
import {
Disc3, Users, Music4, Radio, Heart, BarChart3,
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
AudioLines,
} from 'lucide-react';
export interface NavItemMeta {
@@ -17,9 +18,11 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
tracks: { icon: AudioLines, labelKey: 'sidebar.tracks', to: '/tracks', section: 'library' },
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
luckyMix: { icon: Sparkles, labelKey: 'sidebar.feelingLucky', to: '/lucky-mix', section: 'library' },
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
+2
View File
@@ -0,0 +1,2 @@
/** Main scroll element wrapping `<Routes />` in App (overlay scrollbar). */
export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport';
+46 -19
View File
@@ -128,6 +128,8 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (!state.payload) return;
let ended = false;
const onMove = (e: MouseEvent) => {
// preventDefault stops the browser from treating the mouse movement as
// a text-selection drag, which causes element highlighting and
@@ -136,38 +138,63 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
};
const onUp = () => {
// Clear any residual selection (from the pre-threshold phase).
/** End drag; optionally fire `psy-drop` at the last known cursor position. */
const endDrag = (dispatchDrop: boolean) => {
if (ended || !stateRef.current.payload) return;
ended = true;
window.getSelection()?.removeAllRanges();
// Dispatch a custom event so drop targets can react.
// The payload is in `detail`.
const evt = new CustomEvent('psy-drop', {
bubbles: true,
detail: stateRef.current.payload,
});
// Find element under cursor
const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt);
if (dispatchDrop) {
const evt = new CustomEvent('psy-drop', {
bubbles: true,
detail: stateRef.current.payload,
});
const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt);
}
setState({ payload: null, position: { x: 0, y: 0 } });
};
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
const onUp = () => endDrag(true);
/** Wayland: webview may not get `mouseup` when the pointer leaves the surface — clear the ghost without a drop. */
const onBlur = () => endDrag(false);
const onVisibility = () => {
if (document.hidden) endDrag(false);
};
const onPointerCancel = () => endDrag(false);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
endDrag(false);
}
};
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onUp, true);
document.addEventListener('pointerup', onUp, true);
document.addEventListener('pointercancel', onPointerCancel, true);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onVisibility);
document.addEventListener('keydown', onKeyDown, true);
// Add a class so CSS can show grab cursor and suppress selection
document.body.classList.add('psy-dragging');
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('mouseup', onUp, true);
document.removeEventListener('pointerup', onUp, true);
document.removeEventListener('pointercancel', onPointerCancel, true);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener('keydown', onKeyDown, true);
document.body.classList.remove('psy-dragging');
};
}, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps
}, [state.payload]);
const ctxValue: DragDropContextValue = {
startDrag,
+8 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { serverListDisplayLabel } from '../utils/serverDisplayName';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
@@ -77,12 +78,17 @@ export function useConnectionStatus() {
}, [check]);
const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers);
const serverName = useMemo(
() => (server ? serverListDisplayLabel(server, servers) : ''),
[server, servers],
);
return {
status,
isRetrying,
retry,
isLan: server ? isLanUrl(server.url) : false,
serverName: server?.name ?? '',
serverName,
};
}
+40
View File
@@ -0,0 +1,40 @@
import { useAuthStore } from '../store/authStore';
/**
* Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
*
* Single source of truth for the gate previously this logic was inlined in
* Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
* the sidebarNavReorder filter. All call sites share the same three-way
* predicate:
* 1. User hasn't hidden it via the Settings toggle.
* 2. AudioMuse is enabled for the active server (feature depends on
* audiomuse-backed similar-track quality).
* 3. An active server exists at all.
*
* Callers that additionally care about the "split vs hub" navigation mode
* should combine this with `randomNavMode === 'separate'` explicitly that's
* an orthogonal UI placement concern, not an availability concern.
*/
export function isLuckyMixAvailable(args: {
activeServerId: string | null | undefined;
audiomuseByServer: Record<string, boolean>;
showLuckyMixMenu: boolean;
}): boolean {
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
if (!showLuckyMixMenu) return false;
if (!activeServerId) return false;
return Boolean(audiomuseByServer[activeServerId]);
}
/**
* React hook form subscribes to the three authStore slices the predicate
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
* toggle on/off) re-renders the caller automatically.
*/
export function useLuckyMixAvailable(): boolean {
const activeServerId = useAuthStore(s => s.activeServerId);
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
}
+29 -2
View File
@@ -8,6 +8,7 @@ import { fetchLyricsPlus, hasWordSync } from '../api/lyricsplus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '../utils/lyricsPersistentCache';
import type { Track } from '../store/playerStore';
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
@@ -38,7 +39,9 @@ export interface CachedLyrics {
notFound: boolean;
}
// Session-level cache — survives tab switches and component unmount/remount.
// L1 cache: RAM, survives tab switches and component remount within a session.
// L2 (IndexedDB) lives in `utils/lyricsPersistentCache.ts` — only touched on
// L1 miss so the common case (jumping back to a recent track) stays fully sync.
export const lyricsCache = new Map<string, CachedLyrics>();
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
@@ -103,7 +106,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
setNotFound(false);
setLoading(true);
const store = (entry: CachedLyrics) => {
const applyEntry = (entry: CachedLyrics) => {
if (cancelled) return;
lyricsCache.set(currentTrack.id, entry);
setSyncedLines(entry.syncedLines);
@@ -114,6 +117,14 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
setLoading(false);
};
const store = (entry: CachedLyrics) => {
if (cancelled) return;
applyEntry(entry);
// Persist for the next session (fire-and-forget — failures are silent).
const serverId = useAuthStore.getState().activeServerId ?? '';
putCachedLyrics(lyricsCacheKey(serverId, currentTrack.id), entry);
};
// For offline / hot-cached tracks we have the file locally — read SYLT /
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
// Fast path: both store lookups are synchronous; returns false immediately
@@ -243,6 +254,22 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (cancelled) return;
if (await fetchEmbedded()) return;
// L2: IndexedDB — re-hydrates RAM cache without a network roundtrip.
// Skip for 'lyricsplus' mode since the persisted entry might be from
// the standard pipeline (no word-level sync) and the user explicitly
// wants a fresh lyricsplus attempt.
if (lyricsMode !== 'lyricsplus') {
const serverId = useAuthStore.getState().activeServerId ?? '';
const persisted = await getCachedLyrics(lyricsCacheKey(serverId, currentTrack.id));
if (cancelled) return;
if (persisted) {
// Don't re-write to L2 (it's already there); just hydrate RAM + UI.
lyricsCache.set(currentTrack.id, persisted);
applyEntry(persisted);
return;
}
}
// YouLyPlus mode: try lyricsplus first, silent fallback to standard.
if (lyricsMode === 'lyricsplus') {
if (cancelled) return;
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useRef } from 'react';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { getSong } from '../api/subsonic';
import {
readOrbitState,
writeOrbitHeartbeat,
} from '../utils/orbit';
import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit';
/**
* Orbit guest-side tick hook.
*
* Mounted at the app shell; only does work when the local store says we're
* a guest in an active session. Two independent timers:
*
* - **State read** (2.5 s): pull the canonical state from the session
* playlist's comment and mirror it into the store. Detect session end
* or own kick and tear down.
* - **Heartbeat** (10 s): refresh the guest's own outbox playlist
* comment so the host's participant sweep sees the user as alive.
*
* Reads are best-effort; a transient Navidrome outage just delays state
* updates by a tick or two. The session continues locally as long as the
* playback engine has the current track loaded.
*/
const STATE_READ_TICK_MS = 2_500;
const HEARTBEAT_TICK_MS = 10_000;
/**
* Host must be quiet (no state writes) for this long before we treat the
* session as dead and auto-leave. Well above any normal network blip
* reconnects inside this window are silent. Tuned per user decision:
* manual exits have priority, short reconnects never trigger auto-close.
*/
const HOST_TIMEOUT_MS = 5 * 60_000;
export function useOrbitGuest(): void {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId;
/**
* Last host playback state we *applied* to the local player. Compared
* against the new tick to detect host-side flips (track change /
* play-pause toggle) and against the local player's current state to
* detect guest-side divergence (the guest paused or skipped on their own).
*
* Reset to null on (re-)activation so a fresh session re-syncs from scratch.
*/
const lastAppliedRef = useRef<{ trackId: string | null; isPlaying: boolean } | null>(null);
// ── State read + end/kick detection + auto-sync to host ──────────────
useEffect(() => {
if (!active || !sessionPlaylistId) return;
let cancelled = false;
lastAppliedRef.current = null;
/**
* Load `trackId` into the local player and seek to the host's live
* position. Mirrors the host's `isPlaying` state a guest joining a
* paused host doesn't auto-start, a guest joining a playing host must
* start. Best-effort; silent on miss.
*
* Seek + state-mirror is applied once the engine reports the target
* track as `isPlaying` (polled up to 2 s), with a final fallback apply
* past the deadline so a loading error doesn't leave the guest stuck
* on a silent pause.
*/
const syncToHost = async (trackId: string, hostState: OrbitState): Promise<boolean> => {
try {
const song = await getSong(trackId);
if (!song || cancelled) return false;
const track = songToTrack(song);
// Clamp fraction to [0, 0.99] — if the host's positionAt is unusually
// stale, estimateLivePosition can overshoot the track duration and a
// seek past the end would immediately trigger audio:ended.
const calcFraction = () => {
const targetMs = estimateLivePosition(hostState, Date.now());
const targetSec = Math.max(0, targetMs / 1000);
return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
};
const applyMirror = (): boolean => {
const p = usePlayerStore.getState();
if (cancelled || p.currentTrack?.id !== trackId) return false;
p.seek(calcFraction());
if (hostState.isPlaying && !p.isPlaying) p.resume();
else if (!hostState.isPlaying && p.isPlaying) p.pause();
return true;
};
const player = usePlayerStore.getState();
if (player.currentTrack?.id === trackId) {
return applyMirror();
}
player.playTrack(track, [track]);
// Poll until the engine has the track loaded; fall back to a blind
// apply after 2 s so a stuck load doesn't leave us spinning forever.
return await new Promise<boolean>(resolve => {
const deadline = Date.now() + 2000;
const poll = () => {
if (cancelled) { resolve(false); return; }
const p = usePlayerStore.getState();
const trackReady = p.currentTrack?.id === trackId;
if (trackReady && p.isPlaying) { resolve(applyMirror()); return; }
if (Date.now() >= deadline) { resolve(applyMirror()); return; }
window.setTimeout(poll, 100);
};
window.setTimeout(poll, 100);
});
} catch { return false; }
};
const pull = async () => {
const state = await readOrbitState(sessionPlaylistId);
if (cancelled) return;
if (!state) {
// Session playlist is gone — almost always means the host ended the
// session and the `ended:true` write was missed because we polled
// after the subsequent playlist delete. Surface the same modal the
// explicit `state.ended` branch does; the store still holds the last
// known state so the modal can render the host + session name copy.
// Outbox cleanup runs from the modal's OK handler via leaveOrbitSession.
useOrbitStore.getState().setPhase('ended');
return;
}
useOrbitStore.getState().setState(state);
// Auto-leave after prolonged host silence. We keep polling as long as
// state reads succeed (short reconnects are silent), but if the host
// hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the
// session as effectively dead and surface the exit modal. Manual exit
// still works instantly — the bar's X button short-circuits this path.
if (state.positionAt > 0 && (Date.now() - state.positionAt) > HOST_TIMEOUT_MS) {
useOrbitStore.getState().setError('host-timeout');
return;
}
// Reconcile pending guest suggestions against the host's *playable*
// queue — NOT `state.queue`, which is the suggestion history (every
// submission lands there immediately, even under manual-approval mode
// where the host hasn't actually accepted the track yet).
// `state.playQueue` is the host's real upcoming queue, so a trackId
// appearing there (or as `currentTrack`) means the host has merged it.
if (useOrbitStore.getState().pendingSuggestions.length > 0) {
const landed = new Set<string>();
for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
if (state.currentTrack) landed.add(state.currentTrack.trackId);
useOrbitStore.getState().reconcilePendingSuggestions(landed);
}
// Host signalled session end: surface via `phase`, let the UI handle
// the modal. Outbox cleanup still happens via leaveOrbitSession().
if (state.ended) {
useOrbitStore.getState().setPhase('ended');
return;
}
// Kicked / soft-removed: transition into the error phase with a
// matching errorMessage so the UI can pick the right copy.
const me = useAuthStore.getState().getActiveServer()?.username;
if (me && state.kicked.includes(me)) {
useOrbitStore.getState().setError('kicked');
return;
}
// Soft-remove: only react to markers strictly newer than our own join
// time, otherwise a stale marker from a prior session-life would
// immediately bounce us out on rejoin.
if (me && state.removed && state.removed.length > 0) {
const joinedAt = useOrbitStore.getState().joinedAt ?? 0;
const hit = state.removed.find(r => r.user === me && r.at > joinedAt);
if (hit) {
useOrbitStore.getState().setError('removed');
return;
}
}
// ── Auto-sync host playback into local player ──
// Rules:
// 1. First tick after activation → mirror host (initial join sync,
// no need for the guest to click catch-up to get started).
// 2. Track changed at host → guest follows ONLY if they haven't
// locally diverged. A guest who hit pause should stay paused
// even when the host moves to the next song; otherwise their
// pause button silently un-does itself. If diverged, we just
// advance the anchor so Catch Up stays the opt-in path.
// 3. Same track, host flipped play/pause → mirror only if the local
// player still matches our last-applied host state. If the guest
// paused/resumed locally, we leave them alone — they have to
// click catch-up to opt back in.
const player = usePlayerStore.getState();
const hostTrackId = state.currentTrack?.trackId ?? null;
const hostPlaying = state.isPlaying;
const last = lastAppliedRef.current;
if (!last) {
// Initial sync: only record `last` *after* syncToHost actually
// landed. If the first attempt loses the race (engine not ready,
// stale audio state, network blip), a retry ticker below will try
// again every 500 ms until it succeeds. Without this, the first
// failed sync set `last` anyway and the guest was stuck on their
// pre-join state until they clicked Catch Up.
if (hostTrackId) {
const ok = await syncToHost(hostTrackId, state);
if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else {
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
}
} else if (last.trackId !== hostTrackId) {
const diverged = player.isPlaying !== last.isPlaying;
if (diverged) {
// Guest is running their own show (typically: paused while host
// kept going). Do not load/start the host's new track — just
// track the host state so the catch-up prompt stays accurate.
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else if (hostTrackId) {
void syncToHost(hostTrackId, state);
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else {
if (player.isPlaying) player.pause();
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
}
} else if (last.isPlaying !== hostPlaying) {
// Only mirror when the guest hasn't diverged. We compare against the
// *last applied* host state, not the new one — divergence means the
// local player no longer matches what we last pushed in.
if (player.isPlaying === last.isPlaying) {
if (hostPlaying) player.resume();
else player.pause();
}
// Either way, advance the anchor so we don't keep retrying the same
// flip every tick.
lastAppliedRef.current = { trackId: last.trackId, isPlaying: hostPlaying };
}
};
// Self-scheduling tick: fast-poll (500 ms) while we haven't locked in an
// initial sync yet, fall back to the steady cadence once we're anchored.
// Lets a failed first attempt retry quickly without spamming the network
// for the lifetime of the session.
let timer: number | null = null;
const tick = async () => {
timer = null;
await pull();
if (cancelled) return;
const delay = lastAppliedRef.current === null ? 500 : STATE_READ_TICK_MS;
timer = window.setTimeout(tick, delay);
};
void tick();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [active, sessionPlaylistId]);
// ── Heartbeat ────────────────────────────────────────────────────────
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId) return;
const me = useAuthStore.getState().getActiveServer()?.username;
if (!me) return;
const outboxName = orbitOutboxPlaylistName(sessionId, me);
const beat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void beat();
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId]);
}
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useRef } from 'react';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { getSong } from '../api/subsonic';
import {
writeOrbitState,
writeOrbitHeartbeat,
sweepGuestOutboxes,
applyOutboxSnapshotsToState,
maybeShuffleQueue,
effectiveShuffleIntervalMs,
suggestionKey,
} from '../utils/orbit';
import {
orbitOutboxPlaylistName,
ORBIT_PLAY_QUEUE_LIMIT,
type OrbitState,
type OrbitQueueItem,
} from '../api/orbit';
import { showToast } from '../utils/toast';
import i18n from '../i18n';
/**
* Orbit host-side tick hook.
*
* Mounted once at the app shell level; only does work when the local store
* says we're the host of an active session. Two independent timers:
*
* - **State tick** (2.5 s): snapshot isPlaying + position + current track
* from the player store, patch the local OrbitState, push to the
* session playlist's comment.
* - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's
* comment with a fresh timestamp so the later-added participant
* pipeline can treat the host symmetrically.
*
* Writes are best-effort a transient Navidrome outage just means guests
* see stale state for a tick or two and catch up on the next write.
* Phase 2 does not yet consume anything from guests.
*/
const STATE_TICK_MS = 2_500;
const HEARTBEAT_TICK_MS = 10_000;
export function useOrbitHost(): void {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
// Refs hold the last values we used to build the patch — cheap to
// recompute against, no need to subscribe to every playerStore tick.
const lastPushedAtRef = useRef(0);
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
useEffect(() => {
if (!active || !sessionPlaylistId) return;
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState();
const now = Date.now();
return {
isPlaying: p.isPlaying,
positionMs: Math.round((p.currentTime ?? 0) * 1000),
positionAt: now,
currentTrack: p.currentTrack
? {
trackId: p.currentTrack.id,
// Locally-initiated plays are marked as authored by the host.
// Guest-suggested tracks that later become `currentTrack` will
// carry their original attribution because the queue-consume
// flow keeps the `addedBy` from the guest's outbox.
addedBy: hostUsername,
addedAt: now,
}
: null,
};
};
const pushState = async () => {
const store = useOrbitStore.getState();
const base = store.state;
if (!base) return;
// 1) Sweep every guest outbox: new suggestions + fresh heartbeats.
let afterSweep = base;
try {
const snaps = await sweepGuestOutboxes(base.sid, base.host);
afterSweep = applyOutboxSnapshotsToState(base, snaps);
} catch { /* best-effort; keep old participants and queue */ }
// 2) Merge newly-suggested items into the host's local play queue so
// guest suggestions actually start playing alongside host-chosen
// tracks. Must happen BEFORE the shuffle step so the merge decision
// tracks `addedAt` (immutable) rather than list position.
await mergeNewSuggestionsIntoQueue(afterSweep.queue);
// 3) Shuffle check:
// a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing
// suggestion list). It also bumps `lastShuffle` even when the list
// is too small to reorder — that's the authoritative 15-min marker.
// b) In parallel, we shuffle the *host's* upcoming play queue so the
// mix the guests hear actually changes. `autoShuffle=false` skips
// both.
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
const before = usePlayerStore.getState().queue.length;
usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
}
// 4) Overlay the host's live playback snapshot.
const playerLive = usePlayerStore.getState();
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the
// host's player queue.
const suggesterByTrack = new Map<string, string>();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
trackId: t.id,
addedBy: suggesterByTrack.get(t.id) ?? base.host,
}));
const next: OrbitState = {
...afterShuffle,
...snapshotPlayerPatch(base.host),
playQueue,
playQueueTotal: upcoming.length,
};
// 5) Commit locally + push remote.
useOrbitStore.getState().setState(next);
try {
await writeOrbitState(sessionPlaylistId, next);
lastPushedAtRef.current = Date.now();
} catch { /* best-effort; next tick retries */ }
};
/**
* Resolve each not-yet-merged suggestion via `getSong` and append to the
* player queue. Records a toast per successful append so the host notices
* guest activity. Safe to call every tick the set filter keeps it idempotent.
*/
const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => {
// Opt-out: host turned auto-approve off. Items still accumulate in
// `OrbitState.queue` and show up in the guest view / approval list —
// they just don't flow into the host's actual play queue yet.
const store = useOrbitStore.getState();
const settings = store.state?.settings;
if (settings && settings.autoApprove === false) return;
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
// must not flow through the merge pipeline again — otherwise the tick
// would duplicate the track into the upcoming queue. Declined items
// stay out too; merged items are the existing dedup anchor.
const hostUser = store.state?.host;
const mergedKeys = new Set(store.mergedSuggestionKeys);
const declinedKeys = new Set(store.declinedSuggestionKeys);
const pending = items.filter(q =>
q.addedBy !== hostUser
&& !mergedKeys.has(suggestionKey(q))
&& !declinedKeys.has(suggestionKey(q))
);
if (pending.length === 0) return;
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
const resolved = await Promise.all(pending.map(async q => {
try {
const song = await getSong(q.trackId);
return song ? { q, track: songToTrack(song) } : null;
} catch {
return null;
}
}));
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q)));
if (toEnqueue.length === 0) {
// Mark the failed lookups as seen anyway so we don't keep retrying
// every tick for a track the server can't serve.
markAllAsMerged();
return;
}
// Sprinkle each track at a random spot inside the upcoming range so
// guest suggestions interleave with host-picked tracks rather than
// pile up at the end (where they'd never play until the 15-min shuffle).
const player = usePlayerStore.getState();
for (const { track } of toEnqueue) {
const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1);
const to = live.queue.length;
const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
}
markAllAsMerged();
// Friendly nudge per sweep, not per track — bundled toast if >1.
if (toEnqueue.length === 1) {
const { q, track } = toEnqueue[0];
showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info');
} else {
showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info');
}
};
// Immediate push on mount so guests see fresh state without waiting
// a full tick after the host comes online.
void pushState();
const id = window.setInterval(() => { void pushState(); }, STATE_TICK_MS);
return () => window.clearInterval(id);
}, [active, sessionPlaylistId]);
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId) return;
const server = useOrbitStore.getState().state?.host;
if (!server) return;
const outboxName = orbitOutboxPlaylistName(sessionId, server);
const pushHeartbeat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void pushHeartbeat();
const id = window.setInterval(() => { void pushHeartbeat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId]);
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import {
suggestOrbitTrack,
hostEnqueueToOrbit,
evaluateOrbitSuggestGate,
OrbitSuggestBlockedError,
} from '../utils/orbit';
import { showToast } from '../utils/toast';
/**
* Shared behaviour for song rows that in "normal mode" swallow a full list
* into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites,
* ArtistDetail top-songs, SearchResults, RandomMix, AdvancedSearch).
*
* In an active Orbit session this is too destructive the list would
* propagate to every guest's player. Instead:
*
* - `queueHint()` show a toast telling the user to double-click.
* Safe to call on every single-click; 220 ms debounce
* suppresses the pileup that browsers emit before a
* dblclick fires.
* - `addTrackToOrbit(songId)` cancel any pending hint and add just that
* one track: suggestOrbitTrack for guests,
* hostEnqueueToOrbit for the host.
*
* `orbitActive` is the gate when false, callers should skip the hint and
* run their original bulk-play path unchanged.
*/
export function useOrbitSongRowBehavior() {
const { t } = useTranslation();
const orbitRole = useOrbitStore(s => s.role);
const orbitActive = orbitRole === 'host' || orbitRole === 'guest';
const clickTimerRef = useRef<number | null>(null);
const queueHint = useCallback(() => {
if (clickTimerRef.current !== null) return;
clickTimerRef.current = window.setTimeout(() => {
clickTimerRef.current = null;
showToast(t('albumDetail.orbitDoubleClickHint'), 2400, 'info');
}, 220);
}, [t]);
const addTrackToOrbit = useCallback((songId: string) => {
if (clickTimerRef.current !== null) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
if (orbitRole === 'guest') {
const gate = evaluateOrbitSuggestGate();
if (!gate.allowed && gate.reason === 'muted') {
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
return;
}
suggestOrbitTrack(songId)
.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');
}
});
} else if (orbitRole === 'host') {
hostEnqueueToOrbit(songId)
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
}
}, [orbitRole, t]);
return { orbitActive, queueHint, addTrackToOrbit };
}

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