Compare commits

...

28 Commits

Author SHA1 Message Date
Psychotoxical 901d260bf1 fix(orbit): stop the orphan sweep from deleting live session playlists
The sweep's name regex kept the trailing __ inside the optional _from_ group,
so a bare session name (__psyorbit_<sid>__) never matched and was pruned as
'corrupt' before the reconnect-breadcrumb guard ran. This was latent while
quitting deleted the session; once sessions survive quit (reconnect), the next
launch's sweep deleted the live session, so the reconnect prompt found nothing
to resume. The trailing __ is now anchored outside the group so both session
and outbox names match. Adds a regression test.
2026-06-07 23:03:35 +02:00
Psychotoxical ebef8aa061 fix(orbit): show the reconnect prompt under React StrictMode
The startup preflight set its one-shot ref before the async ran, so under
StrictMode's dev double-invoke (mount -> cleanup -> mount) the cancelled first
run blocked setCandidate while the second mount short-circuited on the ref, and
the prompt never appeared in dev builds. The decided-flag is now set only after
the cancellation guard so the second (real) mount completes the check. Adds a
regression test that renders the modal under StrictMode.
2026-06-07 22:51:16 +02:00
Psychotoxical 9de56bf638 fix(orbit): keep the session alive on app quit so reconnect can offer it
Quitting ran endOrbitSession / leaveOrbitSession, which deleted the session
server-side and wiped the reconnect breadcrumb — so the restart prompt never
had anything to resume. App exit now leaves the session and breadcrumb intact:
quitting suspends the session rather than ending it. A deliberate end stays the
session bar's End button; switching servers still tears the session down.
2026-06-07 22:39:59 +02:00
Psychotoxical 842e041a6e test(orbit): cover reconnect breadcrumb and host resume 2026-06-07 22:28:51 +02:00
Psychotoxical 0de488335e feat(orbit): prompt to rejoin a session after an app restart
On launch, if a breadcrumb points at a still-alive session on the active
server, a modal offers a one-click rejoin with a 30s auto-rejoin countdown
(host resumes hosting, guest rejoins via the normal path). Declining, Escape,
or a failed attempt wipes the breadcrumb. Copy added across all nine locales.
2026-06-07 22:28:45 +02:00
Psychotoxical 2c95a3499c feat(orbit): persist a reconnect breadcrumb and add host-resume
Sessions stay in-memory only; a tiny localStorage breadcrumb (session id,
playlist ids, role, server) is written on host-start / guest-join and wiped on
any clean exit, so a restart can offer to rejoin. Adds resumeOrbitSessionAsHost,
which rebinds a still-live session as host and rebuilds the in-memory
merged-suggestion set from the restored player queue so resuming never
re-enqueues already-queued tracks. The app-start orphan sweep now skips the
breadcrumb's session so a pending reconnect can't be pruned mid-flight.
2026-06-07 22:28:39 +02:00
Psychotoxical de191e45d5 fix(orbit): keep diagnostics popover inside the window on any size
The diagnostics popover used a fixed 920px width and no height cap, so it
overflowed the left window edge on narrow windows and could drop below the
viewport on short ones. Width and position are now computed inline: width is
clamped to the viewport, the right-anchored popover slides inward so neither
edge spills off-screen, and height is capped to the space below the anchor
with the log textarea flex-shrinking to fit.
2026-06-07 22:02:44 +02:00
Psychotoxical 49ad3618a8 fix(themes): show animated-theme warning on the thumbnail in My Themes (#1022) 2026-06-07 21:26:19 +02:00
cucadmuh 4148e063dd fix(home): sync Mainstage hero backdrop with album on fast nav (#1021)
* fix(home): sync Mainstage hero backdrop with album on fast nav

Bind hero background URL to the current slide and drop stale crossfade
layers so rapid carousel clicks no longer show the previous album's art.

* docs(changelog): note Mainstage hero backdrop sync fix (PR #1021)
2026-06-07 21:49:59 +03:00
Psychotoxical 88f7a7bc90 feat(themes): warn about animated themes on high-CPU setups (#1020)
* feat(themes): warn about animated themes on high-CPU setups

Show a warning icon + tooltip on animated themes (those defining
@keyframes) in the store and in Your Themes, on Linux setups where
animation is costly — the Nvidia WebKit quirk is active or compositing
is forced off. The Nvidia detection already runs once at startup; its
result is recorded (theme_animation.rs) and read via a new
theme_animation_risk command — no GPU re-probe. Display-only; never
shown off Linux. The animated flag comes from the registry (store) or
the theme CSS (Your Themes).

* docs(themes): note the animated-theme warning PR in the Theme Store entry
2026-06-07 20:38:36 +02:00
Psychotoxical daa6fbbfd7 feat(dev): --theme-watch flag for live theme authoring (#1019)
Debug builds only: `--theme-watch <theme.css>` polls a local theme file
and pushes it into the running app on save (a lightweight Rust watcher
thread emits a Tauri event). The frontend installs the CSS under its
[data-theme='<id>'] selector and applies it, so the existing
syncInjectedThemes effect re-injects live — no zip re-import, no reload.
Double-gated (debug_assertions + import.meta.env.DEV); never wired in
production.
2026-06-07 19:54:12 +02:00
Psychotoxical aabd342a64 fix(themes): show store thumbnails in 16:9 (#1018)
* fix(themes): show store thumbnails in 16:9

Thumbnails are now 16:9 WebP. Render the store-row preview at 200x112
(16:9) so the screenshot isn't cropped in the grid; the lightbox already
shows it full size, now larger and crisper from the 1280x720 source.

* docs(themes): note the 16:9 thumbnail PR in the Theme Store entry
2026-06-07 19:30:09 +02:00
cucadmuh fc34a0ec59 feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums

Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.

* feat(offline): reactive DEV offline toggle with full disconnect simulation

Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.

* feat(offline): bytes-first favorites when offline browse is active

Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).

* feat(offline): tracks, help, player stats; suspend library picker offline

- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries

* feat(offline): fork disconnect navigation by offline browse capability

When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.

* feat(offline): browse cached playlists when the server is down

List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.

* feat(offline): read-only artist detail and local play-all paths

Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.

* feat(offline): read-only album detail and enqueue from local bytes

Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.

* chore: remove unused import in AlbumCard after enqueue refactor

* feat(offline): unify browse integration contract across the app

Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.

* feat(offline): close browse contract gaps and fix offline Home feed

Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.

* fix(offline): block playbar stars, close audit gaps, trim dead exports

Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.

* docs: add CHANGELOG and credits for offline browse PR #1017

* fix(offline): stop DEV connection probe regression in tests

React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
2026-06-07 15:59:41 +03:00
Psychotoxical f4e1086131 feat(themes): fresher store refresh + external-services notice (#1016)
* feat(themes): fresher store refresh + external-services notice

Manual refresh now fetches the registry from GitHub raw first (~5 min
cache) and falls back to the jsDelivr CDN, so a freshly merged theme shows
up without waiting on — or purging — the shared CDN @main edge (up to
12 h). Normal loads still use the CDN.

Adds a notice on the Theme Store that the catalogue and previews load from
external services (jsDelivr / GitHub), in line with the app's network
transparency. i18n ×9.

* docs(themes): note the store-refresh PR in the Theme Store entry

Now that #1015 is merged, fold PR #1016 into the still-unreleased Theme
Store changelog and credits entry.
2026-06-07 14:34:28 +02:00
Psychotoxical 23f032b274 feat(themes): free-form community themes with a security floor (#1015)
* feat(themes): free-form community themes with a security floor

Community themes are no longer token-only. The in-app guard
(validateThemeCss) now enforces only a security floor — no network
(@import / non-data url()), no scripts (<style>/<script>/expression()/
javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-,
and a 256 KB cap — and otherwise allows any selectors, structure and
animations. validateThemePackage checks the manifest plus that floor.

Themes can react to app state via same-element attributes set on the theme
root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open.

The local-import confirm dialog now notes that imported themes aren't
reviewed and are installed at the user's own risk. Removes the now-unused
bundled token whitelist.

* docs(themes): note free-form themes in the Theme Store entry

Add PR #1015 and a free-form bullet to the still-unreleased Theme Store
changelog and credits entry.
2026-06-07 14:04:38 +02:00
Psychotoxical aad1a6c3f0 fix(themes): route remaining UI colours through theme tokens (#1014)
* fix(themes): route remaining UI colours through theme tokens

An audit found several surfaces still wired to the fixed Catppuccin
palette or hardcoded hex, so community themes could not recolour them:
the 5-star rating, the global search field, the gradient-text flourish,
badges and category-avatar text, and the What's New page + sidebar
banner. Rewire them to existing contract tokens — no new tokens, and the
built-in themes look identical (the values match). Community themes now
control these areas.

Also fixes device-sync rows referencing an undefined --bg-secondary
(they rendered with no background); they now use --bg-card.

* docs(themes): note the theme-coverage PR in the Theme Store entry

Add PR #1014 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
2026-06-07 12:22:54 +02:00
Psychotoxical 10fc489ce9 fix(themes): offline banner for the Theme Store (#1013)
* fix(themes): show an offline banner when the Theme Store is unavailable

When the registry can't be fetched and no catalogue is cached, the store
now shows a clear offline banner (icon, message, retry) in place of the
list, and hides the search/filter toolbar — there is nothing to browse.
The cached-catalogue fallback with its offline indicator is unchanged, so
the store still works offline when a catalogue was previously cached.

* docs(themes): note the offline-banner PR in the Theme Store entry

Add PR #1013 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
2026-06-07 11:48:23 +02:00
Psychotoxical b3573e7107 feat(themes): import a theme from a local .zip (#1012)
* feat(themes): validate and extract locally imported theme packages

Add the backend + validation half of local theme import: users will be
able to load a theme packaged as a .zip (manifest.json + theme.css).

- `import_theme_zip` Tauri command unpacks only manifest.json + theme.css
  from the archive, outside the webview, with an archive-size cap,
  per-entry uncompressed caps, and path-traversal rejection.
- `validateThemePackage` runs the full theme-store contract in-app: the
  manifest schema (field patterns copied from the repo schema), the CSS
  token whitelist with all core tokens required, color-scheme matching
  the declared mode, data-URI restricted to the arrow token, and a guard
  against ids that collide with built-in themes. The existing
  `validateThemeCss` containment guard is reused and runs again at
  injection time. The contract token list is a byte-identical copy of the
  themes repo's allowed-tokens.json.

Covered by validateThemePackage tests for every rejection class.

* feat(themes): add the Import a theme section to the Themes tab

A dedicated section between the theme scheduler and the Theme Store lets
users import a theme from a local .zip. After the package validates, a
confirmation dialog names the theme and its author before it is installed.

A rejected import shows a plain-language explanation aimed at end users;
the raw contract diagnostics (token names, missing fields) are tucked
into a collapsible "Technical details" block for theme authors. Fully
localised across all nine languages.

* docs(themes): changelog + credits for local theme import

Fold the local .zip import (and the store pagination / refresh-scroll
polish) into the still-unreleased Theme Store entry rather than a new
section, and extend the credits line. PRs #1011 and #1012.
2026-06-07 11:35:52 +02:00
Psychotoxical 9fb0d5638b feat(themes): paginate theme store and keep scroll on refresh (#1011)
The community Theme Store grew large enough that browsing it meant
scrolling through the whole catalogue. Paginate it (12 per page) with a
Prev / "Page X of Y" / Next pager that resets to page 1 on filter changes
and scrolls back to the top of the list when paging.

Also fix the refresh button resetting the scroll position. It set the
top-level loading state, which unmounted the list and collapsed the
scroll viewport so it clamped back to the top. Refreshing now keeps the
existing list mounted and only spins the refresh icon; the full-page
loading and error placeholders are reserved for the initial load.

Adds themeStorePagePrev / PageNext / PageStatus across all nine locales
and a ThemeStoreSection test covering pagination, filtering, page reset,
and the refresh-keeps-scroll behaviour.
2026-06-07 10:14:46 +02:00
Psychotoxical f9df918c72 feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1)

Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and
--text-on-accent on the :root base as --ctp-* mappings. They resolve
per-theme automatically and nothing consumes them yet (zero behaviour
change) — groundwork for replacing direct --ctp-* use in components.

* refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2)

Replace every direct --ctp-* reference in component/layout/track CSS and
TSX inline styles with the readable semantic token (--bg-app, --accent,
--highlight, --text-on-accent, …). --ctp-* now survives only as the
Catppuccin palette layer the base maps from, and as the deliberate
categorical rainbow in Composers/Genres/artistsHelpers (left untouched).

This is the readable contract surface for the community theme store.
Divergences (theme set a semantic var != its --ctp- source) are
corrections — the element now uses the theme's real semantic colour.

* feat(themes): add player-bar title/artist color tokens

New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token.

* refactor(themes): token-only theme library (flatten, whitelist, one file per theme)

Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer):

- Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens.

- Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents.

- Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging.

Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins.

* chore(themes): remove seven themes retired after the token refactor

These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry.

* feat(themes): granular tokens — track lists

Wire track rows to per-region tokens: row hover (--row-hover), the now-playing
row + indicator (--row-playing-bg / --row-playing-text), track title/artist/
number/duration text, column-header text, row dividers, and the resize-handle
active colour. Covers the desktop tracklist, the shared song-row (Tracks hub /
search), and the mobile tracklist. Drop a baked border fallback. Visual no-op.

* feat(themes): granular tokens — cards

Wire album and artist cards to per-region tokens (--card-hover-border,
--card-title, --card-subtitle, --card-placeholder-bg). Visual no-op.

* fix(themes): drop undefined/baked colour aliases

Replace the undefined --bg-surface (resolved to nothing — broken placeholder
backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the
baked-hex aliases --color-error / --color-warning with --danger / --warning so
themes can actually recolour them.

* feat(themes): Spectrum demo theme + trim unused cascade tokens

Add a loud built-in demo theme that gives each region its own hue (sidebar
green, player pink, lists cyan, cards gold, menus red, controls blue) so the
per-region granularity is obvious when you switch to it. Drop two unused
cascade tokens (--sidebar-text-active, --row-active-bg) and the unused
on-media block (those media surfaces stay static by design).

* style(themes): make Spectrum demo brutally loud

Full-saturation neon per region (toxic green sidebar, magenta player, cyan
lists, acid-yellow cards, blood-red menus, electric-blue controls, purple
scrollbar) so the per-region separation is unmistakable. The earlier soft
tints were too subtle to read.

* feat(themes): name the granular demo theme Braindead

* feat(themes): granular per-region tokens — cascade layer + sidebar

Add an optional per-region token layer (semantic-cascade.css) so a theme can
recolour individual regions — sidebar hover, player controls, list rows,
menus, inputs, on-media surfaces — independently of the global tokens. Every
token defaults to its base token (or a media-safe literal), so this is a
visual no-op until a theme overrides one; it only adds control points.

Wire the sidebar region as the first consumer and drop the baked grey
fallbacks (--bg-tertiary, etc.) that no theme could reach.

* feat(themes): granular tokens — controls, menus, scrollbar

Wire inputs, buttons, sliders, the custom-select, context menus, submenus and
modals to per-region tokens, and tokenise the scrollbar. Complete B0 by
dropping the last direct --ctp-* references in the input/button/progress/
scrollbar utility CSS.

Fix three undefined-token bugs that fell back to nothing (so no theme could
reach them): --surface-2 (context-menu hover had no highlight), --bg-surface
(submenu create-input had no background), and the baked grey fallbacks in the
custom-select. Every other new token defaults to today's value — a visual
no-op that only adds override points.

* feat(themes): granular tokens — player bar

Wire the desktop player bar's transport controls, time toggle, and overflow
menu to per-region tokens (--player-control, --player-time-toggle-*, etc.).
Fix the undefined --surface-hover/--surface-active grey fallbacks on the time
toggle. Visual no-op; defaults match today's values.

* chore(themes): remove empty theme stub files

Six theme CSS files were reduced to comment-only stubs by the flatten
sweep but their files and @import lines remained. Five are empty
structural companions of now-flattened themes (morpheus, p-dvd,
aero-glass, luna-teal) and two are orphans of cut themes
(order-of-the-phoenix, pandora). Removed the files and their imports.

* feat(themes): runtime injection foundation for the theme store

Plumbing for installed community themes ahead of the in-app store UI,
nothing user-visible yet:

- installedThemesStore: persisted (localStorage) record of installed
  community themes incl. their CSS text, so an active community theme is
  available synchronously at startup (no flash, fully offline).
- themeInjection: reconcile <head> <style data-installed-theme> elements
  with the store; lightweight defense-in-depth sanitize on top of CI.
- themeRegistry: jsDelivr registry client with a 12h localStorage cache
  and stale-on-error fallback.
- App: inject installed themes before applying data-theme, in both webviews.
- themeStore: widen the Theme type to accept dynamic installed ids.

* feat(themes): dedicated Themes settings tab

Move theme selection and the day/night scheduler out of Appearance into
a new dedicated Themes tab — the future home of the community Theme Store.
Appearance keeps grid columns, visual options, UI scale, font and seekbar.

- ThemesTab: theme picker + scheduler (relocated verbatim).
- AppearanceTab: drop the two relocated sections + now-unused imports.
- Register the tab in settingsTabs (Tab union, resolveTab, search index)
  and Settings (tab bar, render, label map).
- i18n: settings.tabThemes in all 9 locales.

* feat(themes): community Theme Store browse + install

Add the Theme Store section to the Themes tab:

- Fetch the jsDelivr registry (12h cache, stale-on-error fallback).
- Search by name/author/description + filter by light/dark + refresh.
- Per-row CDN thumbnail, name, author, description and actions:
  Install / Apply / Update / Uninstall. Installing fetches the CSS,
  persists it (localStorage) and the runtime injection applies it;
  uninstalling the active theme falls back to the matching core.
- Rating slot left reserved (deferred).
- i18n: themeStore* keys in all 9 locales.

* feat(themes): slim bundle to fixed cores + flat Themes tab

Remove the 86 store palettes (incl. braindead) from the app bundle — the
CSS files, their index.css imports, the Theme union and the picker data —
leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave,
Stark HUD, Vision Dark/Navy). Everything else installs from the store.

Themes tab is rebuilt flat (no collapsible accordions):
- "Your Themes": one card grid of the fixed cores + installed community
  themes; click to apply, uninstall on community ones (active theme falls
  back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe
  pill on the colour-blind-safe Vision themes.
- Scheduler day/night options include installed themes.
- Theme Store: alphabetical order, thumbnail lightbox, and a submit hint
  above the search linking to the themes repository.
- Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, …
- ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts.
- i18n for all new strings across 9 locales.

* feat(themes): reset removed-from-bundle themes to a bundled fallback

After slimming the bundle to the six fixed core themes, a profile upgraded
from an older build may have an active or scheduler theme that is now
store-only and not installed — it has no [data-theme] block and would render
as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled
nor installed to a bundled fallback: Mocha for the main + night slots, Latte
for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the
persisted selection in localStorage before React mounts (no flash; Zustand
rehydrates after first paint). No auto-install and no network — the fallback
is always a bundled theme, so it works offline.

* feat(themes): floating back-to-top button on the Themes tab

The Themes tab can get long (theme grid + scheduler + full store list), so
add a floating back-to-top affordance that appears once the page is scrolled
and smooth-scrolls to the top. It is portalled into the route host and
positioned absolute against it — the main scroll viewport sets contain: paint,
which would otherwise make position: fixed resolve against the scrolling box
and drift with the content. Reusable component (scroll viewport id + threshold
props); i18n common.backToTop added in all nine locales.

* feat(themes): accessibility + state polish for the Theme Store

- Reuse the shared CoverLightbox for the thumbnail preview instead of a
  second inline dialog — gains a visible close button and a focus-managed,
  portalled dialog, and drops duplicated markup.
- Theme cards expose aria-pressed so assistive tech announces the selected
  theme, not just the visual check.
- Transient store messages get live-region roles (loading/empty/install
  failure = status, fetch error = alert).
- Thumbnails degrade gracefully when offline/missing (hide the broken-image
  glyph; the thumbnail button no longer stretches with the row, so its
  background can't show as letterbox bars).

* feat(themes): larger store-row thumbnails (120x75 -> 200x125)

The list previews were too small to make a theme out; bump the display size
(same 1.6 aspect). Thumbnails are now served at 720x450, so the larger
display stays crisp.

* fix(themes): bust thumbnail cache on registry change

jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail
is updated the webview keeps showing its cached old image (the path is
unchanged). Append the registry's generatedAt as a cache-busting query to
the thumbnail URLs (list + lightbox); it changes on every themes push, so a
registry refresh makes the webview re-fetch and reflect the current CDN
image instead of a stale one.

* docs(themes): changelog + credits for the Theme Store

Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009)
and the matching line in the Psychotoxical credits.

* fix(themes): address PR review (uninstall hygiene, validation, polish)

Uninstall/scheduler & validation:
- uninstallTheme() repairs every selection slot (active + day + night), not
  just the manual one, and is shared by both uninstall buttons (dedup).
- Validate theme CSS at install time and skip persisting CSS that won't inject
  (no more "installed/active but renders nothing" with no feedback).
- Harden the runtime validator: exactly one rule, scoped exactly to the
  theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no
  at-rules, url() only data:, no expression()/javascript:, size-capped.

Tokens & polish:
- Fix three dangling undefined tokens (--surface-2 x2, --bg-surface).
- Finish the warning/success token sweep (--warning / --positive, themeable).
- Apply the active theme synchronously before React mounts (no first-frame
  flash) and inject installed themes up front.
- One-time, dismissible notice when the slim-bundle migration reset a theme.
- Update badge uses semver, not string inequality.
- Offline/stale indicator in the store; cross-window theme sync; drop the now
  dead REMOVED_THEME_REMAP and Card.mode field.

Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/
malformed), uninstallTheme (slot repair), migration notice. i18n in all nine
locales. Full suite green (1755 tests).

* test(bootstrap): cover startup theme apply + cross-window sync

The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to
bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3%
and failing the frontend hot-path coverage gate (>=70%). Add unit tests for
both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
2026-06-07 02:47:33 +02:00
Psychotoxical 03a1ba9582 Update README.md (#1010) 2026-06-07 01:57:31 +02:00
cucadmuh 2d3c723a6e feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
2026-06-06 22:43:03 +03:00
cucadmuh 40db6b08d2 chore(playback): remove Preload Next Track setting (#1007)
* chore(playback): remove Preload Next Track setting

The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.

* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
2026-06-06 01:01:08 +03:00
cucadmuh a66d932afe fix(preview): Symphonia format sniff and ranged stream startup (#1006)
* fix(preview): Symphonia format sniff and cluster member stream URLs

Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.

* fix(preview): adapt Symphonia sniff branch for main without cluster routing

Keep formatSuffix and cover-ref guards from the cluster work; use
buildStreamUrl on the active server instead of clusterBrowseServerId.

* fix(preview): use ranged HTTP so preview starts without full-file download

Open preview via RangedHttpSource when the server supports byte ranges;
fall back to buffered download otherwise. Gate in-memory probe with
ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio.

* docs: CHANGELOG and credits for preview fix PR #1006

* chore: drop PR #1006 from settings credits (minor fix)

* fix(preview): allow clippy too_many_arguments on audio_preview_play

formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
2026-06-05 22:59:20 +03:00
Frank Stellmacher f706336e58 fix(waveform): repaint on Vite HMR so theme CSS var edits show live (#1005)
* fix(waveform): repaint on Vite HMR so theme CSS var edits show live

The seekbar caches its colours and repaints the canvas on a data-theme
MutationObserver, so a manual theme switch picks up new waveform colours.
Editing a theme's CSS variables via Vite HMR changes the stylesheet but
not data-theme, so the observer never fires and the canvas keeps the old
palette until a theme switch — annoying during theme dev.

Add a dev-only effect that drops the colour cache and repaints on every
HMR update (vite:afterUpdate). import.meta.hot is undefined in production
builds, so the effect is stripped there — no runtime cost.

* fix(waveform): guard HMR effect against partial import.meta.hot in tests

vitest's SSR env exposes a partial import.meta.hot (has .on, no .off), so
the cleanup threw and failed all WaveformSeek/PlayerBar tests. Feature-detect
both .on and .off before wiring the vite:afterUpdate listener.
2026-06-05 18:44:14 +02:00
Frank Stellmacher 1c23305887 feat(queue): add Timeline display mode (#1004)
* feat(queue): add Timeline display mode

A third queue display mode alongside Queue and Playlist. Timeline shows
the full queue anchored on the current track — played history above
(dimmed), upcoming below — with 'History' and 'Up next' dividers and the
current row auto-centered. It already follows shuffle order since the
queue is stored in play order. The header mode button now cycles
queue → timeline → playlist; Settings gains a third option.

* i18n(queue): Timeline mode strings (9 locales)

* docs(changelog): note Timeline queue mode (#1004)
2026-06-05 16:19:20 +02:00
Frank Stellmacher 41157ccaca chore(fullscreen-player): drop dead i18n keys and old CSS (#1002)
Follow-up cleanup after the old fullscreen player was removed (#1001):
- remove the 9 now-unused settings i18n keys (fsPlayerSection,
  fsShowArtistPortrait(+Desc), fsPortraitDim, fsLyricsStyle(+Rail/Apple
  +Descs)) across all 9 locales.
- reduce fullscreen-player-adaptive-portrait.css to the shared lyrics
  overlay styles (FsLyricsApple); drop the old player's .fs-*, the
  .fslm-* lyrics menu, the unused .fsa-fade-* and dead keyframes.
2026-06-05 14:45:03 +02:00
Frank Stellmacher dc4eef1a97 feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1)

New lean fullscreen player: sharp full-bleed background (artist photo, cover
fallback), no blur / no continuous animations. Bottom-left big cover bottom-
flush with a full-width semi-transparent text bar (title with queue position,
artist, year/genre + rating stars, next-up); control row of transport · center
time · actions; full-width seekbar; live clock. Only the seekbar, time readout
and clock update at runtime, each owning its state (no per-tick re-render).
Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue
helpers. Wired in AppShell; old player kept for A/B.

* feat(fullscreen-player): true waveform seekbar instead of thin bar

Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller
canvas grows the bottom cluster upward, shifting the info/control rows up.
Height clamped to 32-52px.

* feat(fullscreen-player): up-next popover + control-bar styling

- Queue button opens a semi-transparent 'Up next' popover anchored bottom
  right; clicking a row jumps to that queue item.
- Larger bottom-right action buttons.
- Control row gets its own darker semi-transparent bar (touches the info bar
  above) for contrast; play button matches the plain transport buttons
  (no white circle, same size).

* feat(fullscreen-player): high-res (2000px) background cover

Fetch the full-screen background cover at the 2000px tier via the existing
on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP
outside the backfill pipeline) instead of the low-res 500px pipeline tier.
usePlaybackCoverArt now forwards a fullRes option to useCoverArt.

* feat(fullscreen-player): scrolling lyrics overlay + control-bar polish

- Lyrics button next to Queue toggles a centered, dark semi-transparent
  scrolling-lyrics overlay (reuses FsLyricsApple).
- Control bar darkened to match the lyrics overlay (0.88).
- Close button sized down to harmonize with the clock.

* feat(fullscreen-player): make rating stars clickable

The rating stars next to the year were display-only. Wire them to
queueSongRating (same path as the player bar / context menu): click to
set, click the current value to clear, with a hover preview. Keeps the
lucide outline look to match the rest of the control bar.

* fix(fullscreen-player): stable, high-res album cover

The foreground cover was keyed per track, so Navidrome's per-track
`mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded
the cover on every song change within the same album. Key it on albumId
(via useAlbumCoverRef) so it stays put while the album is unchanged.

It also used the low-res tier; reuse the fullRes 2000px cover already
fetched for the background so the foreground is crisp and both share a
single fetch/decode.

* refactor(fullscreen-player): remove the old player and its settings

The static rebuild is now the only fullscreen player. Delete the old
FullscreenPlayer component, its old-only parts (FsArt, FsPortrait,
FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test.
Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim) along with the Appearance
'Fullscreen player' section and its search entry. The new player always
shows the artist photo with cover fallback and has its own lyrics toggle.

Shared building blocks used by the static player (FsLyricsApple,
FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait)
are kept.

* i18n(fullscreen-player): translate the new player's strings

Wire the static player's previously English-only strings through i18n
across all 9 locales: now-playing label, track position, up-next label,
queue/lyrics/shuffle controls, and the queue overlay (title, empty,
close). Reuses existing queue.title / common.close keys; adds six new
keys to the player namespace.

* docs(changelog): note fullscreen player rebuild (#1001)
2026-06-05 14:23:47 +02:00
592 changed files with 21085 additions and 20158 deletions
+84
View File
@@ -21,6 +21,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fullscreen player — rebuilt for much lower CPU/RAM
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
### Queue — Timeline display mode
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
### Offline — unified local playback, library index join, and favorites sync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
### Offline — local-bytes browse when the server is down
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
* PlayerBar hides star rating and favorite controls while offline browse is active.
## Changed
### Dependencies — npm and Rust refresh
@@ -41,6 +98,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — Preload Next Track setting removed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
## Fixed
### Servers — complete border on the active server card
@@ -60,6 +126,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Track preview — Symphonia 0.6 format hints and fast stream start
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
### Mainstage — hero backdrop stays in sync when skipping albums quickly
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
-3
View File
@@ -32,9 +32,6 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
+1
View File
@@ -4266,6 +4266,7 @@ dependencies = [
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"psysonic-library",
"reqwest",
"serde",
"serde_json",
+2 -1
View File
@@ -6,6 +6,7 @@ resolver = "2"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -18,7 +19,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license = ""
license.workspace = true
repository = ""
default-run = "psysonic"
edition.workspace = true
+2 -1
View File
@@ -21,6 +21,8 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -38,5 +40,4 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
@@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file(
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
}
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
pub async fn enqueue_offline_library_analysis_from_file(
app: &tauri::AppHandle,
server_index_key: &str,
library_server_id: &str,
track_id: &str,
file_path: &std::path::Path,
explicit_priority: Option<AnalysisBackfillPriority>,
) -> Result<(), String> {
use tokio::io::AsyncReadExt;
use crate::track_analysis_plan::plan_track_analysis_offline_library;
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| e.to_string())?;
let mut prefix = vec![0u8; 16384];
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
prefix.truncate(n);
if prefix.is_empty() {
return Ok(());
}
let content_hash = analysis_cache::md5_first_16kb(&prefix);
let plan = plan_track_analysis_offline_library(
app,
&[server_index_key, library_server_id],
library_server_id,
track_id,
&content_hash,
);
if !plan.any() {
crate::app_deprintln!(
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
track_id,
server_index_key,
library_server_id,
);
return Ok(());
}
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
let format_hint = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| !e.is_empty());
let priority = explicit_priority.unwrap_or_else(|| {
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
});
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
app,
cache_server_id: server_index_key,
enrichment_server_id: library_server_id,
track_id,
bytes: &bytes,
format_hint: format_hint.as_deref(),
priority,
plan,
fetch_ms: 0,
})
.await?;
Ok(())
}
struct OfflineLibraryAnalysisEnqueue<'a> {
app: &'a tauri::AppHandle,
cache_server_id: &'a str,
enrichment_server_id: &'a str,
track_id: &'a str,
bytes: &'a [u8],
format_hint: Option<&'a str>,
priority: AnalysisBackfillPriority,
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
fetch_ms: u64,
}
async fn enqueue_track_analysis_offline_library_with_plan(
args: OfflineLibraryAnalysisEnqueue<'_>,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if args.bytes.is_empty() || !args.plan.any() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
if args.plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
args.track_id,
content_hash,
args.plan.need_waveform,
args.plan.need_loudness,
args.plan.enrichment.any()
);
submit_analysis_cpu_seed(
args.app.clone(),
args.cache_server_id.to_string(),
args.track_id.to_string(),
args.bytes.to_vec(),
args.format_hint.map(str::to_string),
args.priority,
args.fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if args.plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
args.track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
args.app,
args.enrichment_server_id,
args.track_id,
args.bytes,
analysis_emits_ui_events(args.priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: args.cache_server_id.to_string(),
track_id: args.track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
@@ -103,6 +116,32 @@ fn cache_gaps(
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
@@ -117,6 +156,45 @@ fn enrichment_plan(
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
@@ -194,4 +272,14 @@ mod tests {
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -3,6 +3,7 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
+17 -4
View File
@@ -166,15 +166,24 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mut hint = Hint::new();
if let Some(ext) = format_hint {
@@ -200,6 +209,10 @@ impl SizedDecoder {
}
})?;
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
.tracks()
.iter()
+298 -44
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rodio::Player;
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM;
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
/// `format=` query param on Subsonic stream URLs (transcode targets).
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -134,48 +363,24 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -271,6 +476,55 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -3,6 +3,7 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim();
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
trimmed
let cleaned: String = trimmed
.chars()
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
.collect()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
@@ -256,6 +277,13 @@ mod tests {
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
@@ -5,6 +5,7 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
@@ -0,0 +1,364 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -3,6 +3,7 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -3,6 +3,7 @@ name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album(
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
}
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
server_id: String,
songs: Vec<serde_json::Value>,
) -> Result<u32, String> {
use crate::sync::subsonic_song_to_track_row;
use psysonic_integration::subsonic::Song;
if songs.is_empty() {
return Ok(0);
}
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let repo = TrackRepository::new(&runtime.store);
let mut rows = Vec::with_capacity(songs.len());
for raw in songs {
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
rows.push(subsonic_song_to_track_row(
&server_id,
&song,
&raw,
synced_at,
None,
));
}
repo.upsert_batch(&rows)?;
Ok(rows.len() as u32)
}
#[tauri::command]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
@@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> {
})
}
/// All live rows for a Subsonic track id (any server). Used when legacy offline
/// folders name the server by URL index key rather than profile UUID.
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
let rows = stmt
.query_map(params![track_id], row_to_track_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
})
}
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
/// preserve their order in the result; unknown / deleted refs
/// are silently dropped (frontend reads `tracks.length` against
@@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> {
})
}
/// Legacy offline rows keyed by library `server_id` (index key scope).
pub fn list_offline_local_paths(
&self,
server_id: &str,
) -> Result<Vec<(String, String, Option<String>)>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
)?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
@@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-library = { path = "../psysonic-library" }
psysonic-analysis = { path = "../psysonic-analysis" }
psysonic-audio = { path = "../psysonic-audio" }
+100 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
total
}
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
files
}
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
let mut current = path.parent()?;
loop {
match current.file_name().and_then(|s| s.to_str()) {
Some("cache") | Some("library") | Some("favorites") => {
return Some(current.to_path_buf());
}
_ => current = current.parent()?,
}
}
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let boundary_norm = normalize_path_for_prefix(boundary);
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(boundary) {
let dir_norm = normalize_path_for_prefix(&dir);
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
break;
}
match std::fs::read_dir(&dir) {
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
}
}
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
pub fn prune_empty_subdirs_under(root: &Path) {
if !root.is_dir() {
return;
}
let children: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
for child in children {
prune_empty_subdirs_under(&child);
let is_empty = std::fs::read_dir(&child)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = std::fs::remove_dir(&child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,6 +153,17 @@ mod tests {
assert!(path.exists(), "boundary dir must never be removed");
}
#[test]
fn collect_regular_files_under_lists_nested_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
let sub = dir.path().join("Artist/Album");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
let files = collect_regular_files_under(dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
let root = tempfile::tempdir().unwrap();
@@ -98,4 +175,25 @@ mod tests {
assert!(!child.exists(), "empty leaf should be pruned");
assert!(parent.exists(), "non-empty parent must stay");
}
#[test]
fn local_tier_boundary_from_path_finds_cache_root() {
let root = tempfile::tempdir().unwrap();
let track = root
.path()
.join("vol/media/cache/my.server/Artist/Album/track.flac");
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
let boundary = local_tier_boundary_from_path(&track).unwrap();
assert_eq!(boundary, root.path().join("vol/media/cache"));
}
#[test]
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
std::fs::create_dir_all(&cache).unwrap();
prune_empty_subdirs_under(&root.path().join("cache"));
assert!(!cache.exists());
assert!(root.path().join("cache").exists(), "tier root preserved");
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
pub mod local;
+50
View File
@@ -5,6 +5,8 @@ pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -114,6 +116,38 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
// Poll a local theme.css and push it into the running app on save,
// so theme authors get a live loop without re-importing a zip. The
// frontend (dev only) installs it under the id in its
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
#[cfg(debug_assertions)]
{
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
match args.get(i + 1).cloned() {
Some(path) => {
eprintln!("[theme-watch] watching {path}");
let handle = app.handle().clone();
std::thread::spawn(move || {
let p = std::path::PathBuf::from(&path);
let mut last_css = String::new();
loop {
if let Ok(css) = std::fs::read_to_string(&p) {
if css != last_css {
last_css = css.clone();
let _ = handle.emit("theme-watch:css", css);
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
}
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
}
}
}
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(app.handle())
@@ -593,6 +627,7 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
greet,
theme_import::import_theme_zip,
backup_export_library_db,
backup_import_library_db,
backup_export_full,
@@ -619,6 +654,7 @@ pub fn run() {
performance_cpu_snapshot,
set_subsonic_wire_user_agent,
no_compositing_mode,
theme_animation_risk,
linux_xdg_session_type,
is_tiling_wm_cmd,
open_mini_player,
@@ -715,6 +751,7 @@ pub fn run() {
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
@@ -775,6 +812,19 @@ pub fn run() {
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::download_track_local,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
@@ -23,6 +23,7 @@ pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
theme_animation_risk,
};
#[cfg(target_os = "linux")]
pub(crate) use platform::{
@@ -137,6 +137,29 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
}
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
/// Tauri command: true when theme animations may be costly on this setup —
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
// Compositing forced off → GPU-accelerated effects/animation are costly.
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
.unwrap_or(false)
{
return true;
}
crate::theme_animation::nvidia_quirk_active()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[tauri::command]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
+6 -1
View File
@@ -17,11 +17,16 @@ fn apply_linux_webkit_nvidia_quirk() {
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
// Detect once and record it for the UI's animated-theme CPU-load warning,
// so the theme_animation_risk command never has to re-probe the GPU.
let kind = needs_workaround();
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
matches!(s.split(',').next().map(str::trim), Some("x11"))
});
if forced_x11_gdk {
match needs_workaround() {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
set_webkit_disable_dmabuf_renderer();
+22
View File
@@ -0,0 +1,22 @@
//! Startup-recorded display hint for the theme system.
//!
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
//! before the webview/GTK init). We record whether it was needed so the UI can
//! warn that animated themes may raise CPU load on this setup — without
//! re-probing the GPU later. Read via the `theme_animation_risk` command
//! (`lib_commands::app_api::platform`).
use std::sync::OnceLock;
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
pub fn set_nvidia_quirk_active(active: bool) {
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
}
/// Whether the Nvidia WebKit quirk was needed at startup. False when unrecorded
/// (non-Linux, or GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`).
pub(crate) fn nvidia_quirk_active() -> bool {
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
}
+99
View File
@@ -0,0 +1,99 @@
//! Local theme-package import.
//!
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
//! to the frontend, which runs the full theme-store contract validation
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
//!
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
//! in Rust, outside the webview, and every read is size-capped so a malformed
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
//! memory or escape the archive.
use std::io::Read;
use serde::Serialize;
/// On-disk archive cap. A real token-only theme zip is a few KB.
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
/// Per-entry uncompressed caps — mirror the frontend/CI limits
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
const MAX_CSS_BYTES: usize = 256 * 1024;
#[derive(Serialize)]
pub struct ImportedThemeFiles {
pub manifest: String,
pub css: String,
}
#[tauri::command]
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
let len = file
.metadata()
.map_err(|e| format!("cannot read file info: {e}"))?
.len();
if len > MAX_ARCHIVE_BYTES {
return Err(format!(
"archive is too large (> {} KB)",
MAX_ARCHIVE_BYTES / 1024
));
}
let mut archive =
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
Ok(ImportedThemeFiles { manifest, css })
}
/// Find the first non-directory entry whose file name equals `wanted` (at the
/// archive root or under a single wrapping folder), reject path traversal, and
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
/// actual read are bounded, so a lying header cannot allocate past the cap.
fn read_capped_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
wanted: &str,
cap: usize,
) -> Result<Option<String>, String> {
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("corrupt archive entry: {e}"))?;
if entry.is_dir() {
continue;
}
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
let base = match entry.enclosed_name() {
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
},
None => return Err("archive contains an unsafe path".to_string()),
};
if base != wanted {
continue;
}
if entry.size() > cap as u64 {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
let mut buf = Vec::new();
entry
.by_ref()
.take(cap as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
if buf.len() > cap {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
return String::from_utf8(buf)
.map(Some)
.map_err(|_| format!("{wanted} is not valid UTF-8"));
}
Ok(None)
}
+56
View File
@@ -1,6 +1,10 @@
import { useEffect } from 'react';
import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { useLyricsStore } from './store/lyricsStore';
import { useThemeStore } from './store/themeStore';
import { useInstalledThemesStore } from './store/installedThemesStore';
import { syncInjectedThemes } from './utils/themes/themeInjection';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
import { getWindowKind } from './app/windowKind';
@@ -13,14 +17,66 @@ export default function App() {
useThemeStore(s => s.theme);
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const installedThemes = useInstalledThemesStore(s => s.themes);
// Document-attribute hooks are shared between both window kinds — each
// webview has its own `document`, and theme / font / track-preview tokens
// are read by CSS in both trees.
// Installed community themes have no build-time CSS — inject their
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
// store. Runs before the data-theme effect below so the matching style exists
// when the attribute is applied. The store hydrates synchronously, so an
// active community theme is painted without a network round-trip.
useEffect(() => {
syncInjectedThemes(installedThemes);
}, [installedThemes]);
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
// selector and apply it — the syncInjectedThemes effect above re-injects, so
// authoring is live without re-importing a zip. Never wired in production.
useEffect(() => {
if (!import.meta.env.DEV) return;
let unlisten: (() => void) | undefined;
void import('@tauri-apps/api/event').then(({ listen }) => {
const sub = listen<string>('theme-watch:css', ({ payload }) => {
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
if (!id) return;
useInstalledThemesStore.getState().install({
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
});
useThemeStore.getState().setTheme(id);
});
// Guard the mocked-in-tests case where listen() isn't a promise.
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
}).catch(() => {});
return () => unlisten?.();
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
// Expose app state on the theme root so themes can react to it with a
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
// The set of allowed state attributes is the contract's `stateSelectors`.
// (Sidebar-collapsed is set in AppShell, where that state lives.)
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
}, [isPlaying]);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
useEffect(() => {
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
}, [isFullscreenOpen]);
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
useEffect(() => {
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
}, [lyricsOpen]);
useEffect(() => {
document.documentElement.setAttribute('data-font', font);
}, [font]);
+24
View File
@@ -473,6 +473,18 @@ export function libraryGetTrack(
.then(track => (track ? { ...track, serverId } : track));
}
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
export function libraryUpsertSongsFromApi(
serverId: string,
songs: unknown[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
}
/** `library_get_tracks_batch` cap (spec §7.1). */
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
@@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
})));
}
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
if (refs.length === 0) return [];
const out: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
+5
View File
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
default: { get: vi.fn() },
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: () => true,
shouldAttemptSubsonicForServer: () => true,
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
+15
View File
@@ -1,4 +1,8 @@
import { useAuthStore } from '../store/authStore';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
RandomSongsFilters,
@@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
@@ -71,6 +76,7 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
@@ -212,6 +218,7 @@ export async function getAlbumListForServer(
offset = 0,
extra: Record<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForServer(serverId)) return [];
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
type,
size,
@@ -224,6 +231,7 @@ export async function getAlbumListForServer(
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForActiveServer()) return null;
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
@@ -233,6 +241,7 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
try {
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
return data.song ?? null;
@@ -242,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForActiveServer()) {
throw new Error('Subsonic unavailable');
}
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -251,6 +263,9 @@ export async function getAlbumForServer(
serverId: string,
id: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
+21 -1
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { api } from './subsonicClient';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer } from './subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
@@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
@@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('../utils/offline/pinnedOfflineSync')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
+3
View File
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
}));
import { getArtist } from './subsonicArtists';
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
+3
View File
@@ -1,5 +1,6 @@
import { getArtist } from './subsonicArtists';
import { getAlbum } from './subsonicLibrary';
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
+3
View File
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: apiForServerMock,
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
describe('subsonicScrobble', () => {
beforeEach(() => {
+2
View File
@@ -1,6 +1,7 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
async function scrobbleOnServer(
serverId: string,
@@ -8,6 +9,7 @@ async function scrobbleOnServer(
submission: boolean,
time?: number,
): Promise<void> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
const params: Record<string, unknown> = { id, submission };
if (time !== undefined) params.time = time;
await apiForServer(serverId, 'scrobble.view', params);
+55 -9
View File
@@ -1,4 +1,4 @@
import { api, libraryFilterParams } from './subsonicClient';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
@@ -15,6 +15,17 @@ import type {
SubsonicSong,
} from './subsonicTypes';
function parseStarred2Response(data: {
starred2?: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}): StarredResults {
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function getStarred(): Promise<StarredResults> {
const data = await api<{
starred2: {
@@ -23,21 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
return parseStarred2Response(data);
}
/** Starred entities for an explicit saved server (not necessarily the active one). */
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
const data = await apiForServer<{
starred2: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
return parseStarred2Response(data);
}
function resolveStarServerId(meta?: StarPatchMeta): string | null {
return meta?.serverId ?? useAuthStore.getState().activeServerId;
}
async function starApi(
serverId: string | null | undefined,
endpoint: string,
params: Record<string, string>,
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) throw new Error('No server for star API');
if (sid === useAuthStore.getState().activeServerId) {
await api(endpoint, params);
} else {
await apiForServer(sid, endpoint, params);
}
}
export async function star(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
const serverId = useAuthStore.getState().activeServerId;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'star.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -45,19 +85,22 @@ export async function star(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
.catch(() => {});
}
export async function unstar(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
const serverId = useAuthStore.getState().activeServerId;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'unstar.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -65,6 +108,9 @@ export async function unstar(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
.catch(() => {});
}
export async function setRating(id: string, rating: number): Promise<void> {
+6
View File
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
displayArtist?: string;
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
discTitles?: SubsonicDiscTitle[];
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicDiscTitle {
@@ -90,6 +92,8 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
@@ -144,6 +148,8 @@ export interface SubsonicArtist {
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicGenre {
+24 -7
View File
@@ -11,16 +11,19 @@ import PlayerBar from '../components/PlayerBar';
import BottomNav from '../components/BottomNav';
import { useIsMobile } from '../hooks/useIsMobile';
import LiveSearch from '../components/LiveSearch';
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
import NowPlayingDropdown from '../components/NowPlayingDropdown';
import QueuePanel from '../components/QueuePanel';
import AppRoutes from './AppRoutes';
import FullscreenPlayer from '../components/FullscreenPlayer';
import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
import ContextMenu from '../components/ContextMenu';
import SongInfoModal from '../components/SongInfoModal';
import DownloadFolderModal from '../components/DownloadFolderModal';
import GlobalConfirmModal from '../components/GlobalConfirmModal';
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import OrbitReconnectModal from '../components/OrbitReconnectModal';
import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
@@ -38,7 +41,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
import { useOrbitGuest } from '../hooks/useOrbitGuest';
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
@@ -51,11 +55,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
import '../store/queueResolverBridge';
@@ -105,8 +109,10 @@ export function AppShell() {
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const hasOfflineContent = offlineCtx.hasBrowsingContent;
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
@@ -134,7 +140,8 @@ export function AppShell() {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname, location.state]);
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
useOfflineLibraryFilterSuspend();
useEffect(() => {
initializeFromServerQueue();
@@ -173,6 +180,13 @@ export function AppShell() {
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
}, [isSidebarCollapsed, setSidebarCollapsed]);
// Expose sidebar state on the theme root so themes can react with a
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
// `stateSelectors`). Other state attributes are set in App.tsx.
useEffect(() => {
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
}, [isSidebarCollapsed]);
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
// nudge WebKit awake on every input/textarea focus via a sync
@@ -241,6 +255,7 @@ export function AppShell() {
<div className="main-content-zoom">
<header className="content-header">
<LiveSearch />
{import.meta.env.DEV && <DevNetworkModeToggle />}
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
@@ -259,7 +274,7 @@ export function AppShell() {
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
)}
<div className="content-body app-shell-route-host">
<OverlayScrollArea
@@ -305,8 +320,10 @@ export function AppShell() {
<SongInfoModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<ThemeMigrationNotice />
<OrbitAccountPicker />
<OrbitHelpModal />
<OrbitReconnectModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
+28
View File
@@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
@@ -98,6 +104,28 @@ export default function MainApp() {
return initHotCachePrefetch();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
void (async () => {
await runLegacyOfflineFileMigration();
const servers = useAuthStore.getState().servers;
for (const server of servers) {
await reconcileLibraryTierForServer(server.id);
}
scheduleResumeIncompleteOfflinePins();
})();
const stopInvalidation = initLocalPlaybackInvalidation();
const stopFavoritesSync = initFavoritesOfflineSync();
const stopPinnedOfflineSync = initPinnedOfflineSync();
const stopOfflineResume = initResumeIncompleteOfflinePins();
return () => {
stopInvalidation();
stopFavoritesSync();
stopPinnedOfflineSync();
stopOfflineResume();
};
}, [migrationReady, serverIdsKey]);
useEffect(() => {
if (!migrationReady) return;
useGlobalShortcutsStore.getState().registerAll();
+59
View File
@@ -24,10 +24,14 @@ vi.mock('./windowKind', () => ({
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import {
applyThemeAtStartup,
installCrossWindowThemeSync,
pushLoggingModeToBackend,
pushUserAgentToBackend,
runPreReactBootstrap,
} from './bootstrap';
import { useThemeStore } from '../store/themeStore';
import { useInstalledThemesStore } from '../store/installedThemesStore';
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
@@ -48,6 +52,8 @@ beforeEach(() => {
afterEach(() => {
setUserAgent(ORIGINAL_USER_AGENT);
document.documentElement.removeAttribute('data-theme');
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
});
describe('pushUserAgentToBackend', () => {
@@ -143,3 +149,56 @@ describe('runPreReactBootstrap', () => {
// the bootstrap doesn't second-guess that decision.
});
});
describe('applyThemeAtStartup', () => {
const setPersistedTheme = (state: Record<string, unknown>) =>
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
it('does nothing when there is no persisted theme', () => {
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
it('sets data-theme to the active theme (scheduler off)', () => {
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
});
it('injects installed community themes up front', () => {
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
version: 1,
}));
applyThemeAtStartup();
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
});
it('does not throw on malformed storage', () => {
localStorage.setItem('psysonic_theme', '{not json');
expect(() => applyThemeAtStartup()).not.toThrow();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
});
describe('installCrossWindowThemeSync', () => {
it('rehydrates the matching store on a cross-window storage event', () => {
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
installCrossWindowThemeSync();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
expect(themeRehydrate).toHaveBeenCalled();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
expect(installedRehydrate).toHaveBeenCalled();
themeRehydrate.mockClear();
installedRehydrate.mockClear();
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
expect(themeRehydrate).not.toHaveBeenCalled();
expect(installedRehydrate).not.toHaveBeenCalled();
});
});
+67
View File
@@ -1,6 +1,10 @@
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '../utils/themes/themeMigration';
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
import { syncInjectedThemes } from '../utils/themes/themeInjection';
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
/** Sync backend HTTP User-Agent from the main webview once at startup. */
export function pushUserAgentToBackend(): void {
@@ -35,6 +39,63 @@ export function pushLoggingModeToBackend(): void {
}
}
function readInstalledThemes(): InstalledTheme[] {
try {
const raw = localStorage.getItem('psysonic_installed_themes');
if (!raw) return [];
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
} catch {
return [];
}
}
/**
* Apply the active theme synchronously, before React mounts, so the first paint
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
* first paint, so without this a non-Mocha active theme (every light theme and
* every installed community theme) flashes the `:root` Mocha default for a
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
* inject installed community themes' CSS up front. Runs after the migration so
* the persisted ids are already resolved.
*/
export function applyThemeAtStartup(): void {
try {
const raw = localStorage.getItem('psysonic_theme');
if (!raw) return; // fresh profile — the :root default is correct
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const s = parsed.state;
if (!s) return;
syncInjectedThemes(readInstalledThemes());
const effective = getScheduledTheme({
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme ?? 'mocha'),
themeDay: String(s.themeDay ?? 'latte'),
themeNight: String(s.themeNight ?? 'mocha'),
timeDayStart: String(s.timeDayStart ?? '07:00'),
timeNightStart: String(s.timeNightStart ?? '19:00'),
});
if (effective) document.documentElement.setAttribute('data-theme', effective);
} catch {
// Non-fatal — App's effects apply the theme after mount.
}
}
/**
* Keep theme state in sync across webviews (main mini player). Zustand
* persist does not sync across windows on its own; the `storage` event fires in
* *other* windows when localStorage changes, so rehydrate the relevant store
* there. Installing/applying/uninstalling in one window then live-updates the
* other (App's effects re-run and re-apply `data-theme` / injected styles).
*/
export function installCrossWindowThemeSync(): void {
if (typeof window === 'undefined') return;
window.addEventListener('storage', (e) => {
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
});
}
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
export function markDevBuildDocument(): void {
if (import.meta.env.DEV) {
@@ -46,6 +107,12 @@ export function markDevBuildDocument(): void {
export function runPreReactBootstrap(): void {
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
getWindowKind();
// Reset any persisted theme that is no longer bundled and not installed, so
// the store hydrates onto a paintable theme (no unstyled-:root flash).
migrateThemeSelection();
// Paint the correct theme on the very first frame (no Mocha flash).
applyThemeAtStartup();
installCrossWindowThemeSync();
markDevBuildDocument();
pushUserAgentToBackend();
pushLoggingModeToBackend();
+28 -17
View File
@@ -1,14 +1,13 @@
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
@@ -17,12 +16,14 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { resolveCoverDisplayTier } from '../cover/tiers';
import { acquireUrl } from '../utils/imageCache/urlPool';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -63,21 +64,28 @@ function AlbumCard({
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
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}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const offlineServerId = album.serverId ?? activeServerId;
const localEntries = useLocalPlaybackStore(s => s.entries);
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
const albumLinkQuery = useMemo(
() => appendServerQuery(linkQuery, album.serverId),
[linkQuery, album.serverId],
);
void localEntries;
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
const coverServerScope = useMemo(
() => coverServerScopeForServerId(album.serverId),
[album.serverId],
);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
@@ -88,7 +96,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: linkQuery });
navigateToAlbum(album.id, { search: albumLinkQuery });
};
return (
@@ -182,10 +190,13 @@ function AlbumCard({
onClick={async e => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
const tracks = await fetchAlbumTracks(
album.id,
offlineServerId || undefined,
);
if (tracks.length > 0) enqueue(tracks);
} catch {
// Network failure — silent (toast would be too noisy for a hover action)
// Unavailable offline or network failure — silent on hover action
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
+129 -94
View File
@@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { tooltipAttrs } from './tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
/** True when the album artist label means "no single artist" `getArtistInfo`
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
@@ -74,7 +75,7 @@ interface AlbumHeaderProps {
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
@@ -91,6 +92,8 @@ interface AlbumHeaderProps {
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
actionPolicy?: OfflineActionPolicy;
}
export default function AlbumHeader({
@@ -117,7 +120,9 @@ export default function AlbumHeader({
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
actionPolicy,
}: AlbumHeaderProps) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
@@ -222,7 +227,7 @@ export default function AlbumHeader({
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={entityRatingSupport === 'track_only'}
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
@@ -250,14 +255,16 @@ export default function AlbumHeader({
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
{policy.canFavorite && (
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className="album-icon-btn album-icon-btn--sm"
@@ -269,7 +276,7 @@ export default function AlbumHeader({
<Share2 size={16} />
</button>
{showBioButton && (
{showBioButton && policy.canShowBio && (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
@@ -280,44 +287,57 @@ export default function AlbumHeader({
</button>
)}
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
{policy.canDownload && (
downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)
)}
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
{policy.canPinOffline && (
offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'queued' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
</button>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)
)}
</div>
</div>
@@ -348,13 +368,15 @@ export default function AlbumHeader({
>
<ListPlus size={16} />
</button>
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
{policy.canFavorite && (
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
type="button"
className="btn btn-surface"
@@ -366,7 +388,7 @@ export default function AlbumHeader({
</button>
</div>
{showBioButton && (
{showBioButton && policy.canShowBio && (
<button
className="btn btn-surface"
id="album-bio-btn"
@@ -377,47 +399,60 @@ export default function AlbumHeader({
</button>
)}
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
{policy.canDownload && (
downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
)
)}
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
{policy.canPinOffline && (
offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'queued' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)
)}
</div>
)}
+1 -1
View File
@@ -251,7 +251,7 @@ export default function AlbumRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
+12 -3
View File
@@ -1,6 +1,6 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import React, { useState, useEffect } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import { useTracklistColumns } from '../utils/useTracklistColumns';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
actionPolicy?: OfflineActionPolicy;
}
// ── AlbumTrackList ────────────────────────────────────────────────────────────
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
sortKey,
sortDir,
onSort,
actionPolicy,
}: AlbumTrackListProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
);
const currentTrackId = currentTrack?.id ?? null;
const displayCols = useMemo(
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
[policy.canFavorite, visibleCols],
);
if (isMobile) {
return (
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
>
<TracklistHeaderRow
visibleCols={visibleCols}
visibleCols={displayCols}
gridStyle={gridStyle}
sortKey={sortKey}
sortDir={sortDir}
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
key={song.id}
song={song}
globalIdx={globalIdx}
visibleCols={visibleCols}
visibleCols={displayCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
actionPolicy={policy}
/>
);
})}
+10 -3
View File
@@ -1,11 +1,13 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React from 'react';
import React, { useMemo } from 'react';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface Props {
artist: SubsonicArtist;
@@ -18,12 +20,17 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const coverServerScope = useMemo(
() => coverServerScopeForServerId(artist.serverId),
[artist.serverId],
);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
return (
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
>
<div className="artist-card-avatar">
{coverRef ? (
+1 -1
View File
@@ -117,7 +117,7 @@ export default function ArtistRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<ArtistCardLocal
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface BackToTopButtonProps {
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
viewportId?: string;
/** Show the button once the viewport is scrolled past this many pixels. */
threshold?: number;
}
/**
* A floating "back to top" affordance for long pages. Watches a scroll viewport
* (the overlay-scroll element wrapping the routes by default) and, once it is
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
* top reusing the same `getElementById(...).scrollTo` pattern AppShell uses
* for its route-change scroll reset.
*
* The button is portalled into `.app-shell-route-host` and positioned
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
* would otherwise make a `position: fixed` child resolve against the *scrolling*
* box (so it would drift with the content). The route host is a non-contained,
* `position: relative` ancestor that spans exactly the content area (between the
* sidebar and queue, above the player bar), so the button stays pinned to the
* visible viewport corner regardless of scroll.
*/
export default function BackToTopButton({
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
threshold = 400,
}: BackToTopButtonProps) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
const el = document.getElementById(viewportId);
if (!el) return;
const onScroll = () => setVisible(el.scrollTop > threshold);
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [viewportId, threshold]);
if (!visible || !host) return null;
return createPortal(
<button
type="button"
className="back-to-top-btn"
onClick={() =>
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
}
aria-label={t('common.backToTop')}
data-tooltip={t('common.backToTop')}
data-tooltip-pos="left"
>
<ArrowUp size={18} aria-hidden="true" />
</button>,
host,
);
}
+6 -2
View File
@@ -1,5 +1,6 @@
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
@@ -596,7 +597,10 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const data = await resolveAlbum(serverId, album.id);
if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
/* silent — toast would be too noisy for a hover action */
+1 -1
View File
@@ -247,7 +247,7 @@ export default function CachedImage({
};
const fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
: {};
return (
+17
View File
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
useOfflineBrowseContext: () => ({
active: false,
serverId: 'srv-1',
capabilities: {
localLibrary: false,
favorites: false,
playlists: false,
manualPins: false,
playerStats: false,
},
hasBrowseCapability: false,
hasBrowsingContent: false,
connStatus: 'connected' as const,
}),
}));
import ContextMenu from './ContextMenu';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
+28
View File
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '../hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useNavigate } from 'react-router-dom';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import {
offlineActionPolicy,
type OfflineSurface,
} from '../utils/offline/offlineActionPolicy';
import ContextMenuItems from './contextMenu/ContextMenuItems';
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
switch (type) {
case 'album':
case 'multi-album':
return 'contextMenuAlbum';
case 'artist':
case 'multi-artist':
return 'contextMenuArtist';
case 'playlist':
case 'multi-playlist':
return 'contextMenuPlaylist';
default:
return 'contextMenuSong';
}
}
export { AddToPlaylistSubmenu };
@@ -180,6 +201,12 @@ export default function ContextMenu() {
const downloadAlbum = downloadAlbumAction;
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const offlinePolicy = offlineActionPolicy(
contextMenuSurfaceForType(type),
offlineBrowseActive,
);
if (!contextMenu.isOpen || !contextMenu.item) return null;
return (
@@ -233,6 +260,7 @@ export default function ContextMenu() {
isStarred={isStarred}
pinToPlaybackServer={pinToPlaybackServer}
navigateLibrary={navigateLibrary}
offlinePolicy={offlinePolicy}
/>
</div>
</>
+23
View File
@@ -0,0 +1,23 @@
import { Cloud, CloudOff } from 'lucide-react';
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
export default function DevNetworkModeToggle() {
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
if (!import.meta.env.DEV) return null;
return (
<button
type="button"
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
onClick={toggle}
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
aria-pressed={forceOffline}
>
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
<span>{forceOffline ? 'Offline' : 'Online'}</span>
</button>
);
}
-188
View File
@@ -1,188 +0,0 @@
/**
* `FullscreenPlayer` characterization (Phase F5c).
*
* Includes the §4.5 regression test from the v2 plan the cover image
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
* third argument selects the fallback path that avoids a double
* crossfade (fetchUrl blobUrl). A refactor that "tidies up" the
* useCachedUrl call sites would silently regress the FS player.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
getLyricsBySongId: vi.fn(async () => null),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmLoveTrack: vi.fn(async () => undefined),
lastfmUnloveTrack: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
// so we can assert the third positional arg `false` is preserved.
vi.mock('./CachedImage', async () => {
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
return {
...actual,
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
};
});
import FullscreenPlayer from './FullscreenPlayer';
import { useCachedUrl } from './CachedImage';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
vi.mocked(useCachedUrl).mockClear();
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
afterEach(() => {
vi.mocked(useCachedUrl).mockClear();
});
describe('FullscreenPlayer — render', () => {
it('renders the labelled Fullscreen Player dialog', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
});
it('exposes the Close Fullscreen button', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
});
});
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
// The component calls `useCachedUrl` twice:
// - line 338: for the small art box (default behaviour, opt=true)
// - line 674: for the cover (opt=false, no fetchUrl fallback)
// The `false` arg is load-bearing — it avoids a double crossfade by
// routing through the cache-only path. Pin it.
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const coverCall = calls.find(c => c[2] === false);
expect(coverCall).toBeDefined();
expect(typeof coverCall?.[1]).toBe('string');
expect(String(coverCall?.[1])).toContain('album-1');
});
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const defaultOptCalls = calls.filter(c => c[2] !== false);
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
});
});
describe('FullscreenPlayer — control wiring', () => {
it('clicking Close Fullscreen calls the onClose prop', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const onClose = vi.fn();
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={onClose} />,
);
fireEvent.click(getByLabelText('Close Fullscreen'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('clicking Stop calls stop()', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Stop'));
expect(stopSpy).toHaveBeenCalledTimes(1);
});
it('clicking Previous Track calls previous()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 1,
currentTrack: makeTrack({ id: 'b' }),
});
usePlayerStore.setState({ currentTime: 5 });
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Previous Track'));
expect(prevSpy).toHaveBeenCalledTimes(1);
});
it('clicking Next Track calls next()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 0,
currentTrack: makeTrack({ id: 'a' }),
});
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Next Track'));
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it('clicking Repeat cycles via toggleRepeat', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(usePlayerStore.getState().repeatMode).toBe('off');
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('all');
});
});
-228
View File
@@ -1,228 +0,0 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
import { FsArt } from './fullscreenPlayer/FsArt';
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
import { useFsIdleFade } from '../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../hooks/useQueueTracks';
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
// Derive isStarred inside the selector so we only re-render when the boolean
// actually flips — not when any unrelated track's star status changes.
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
const artUrl = artCover.src;
const artKey = artCover.cacheKey;
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
const coverUrl = portraitCover.src;
const coverKey = portraitCover.cacheKey;
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Dynamic accent color extracted from the current album cover, applied as
// --dynamic-fs-accent on the root element. Cache hits return instantly so
// same-album tracks reuse the color without re-fetching.
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
// Artist image → portrait on right. Falls back to cover art.
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
// (thin-state): the next ref resolves from the cache (the prefetch window
// around the current index keeps it warm).
const queueIndex = usePlayerStore(s => s.queueIndex);
const nextTrack = useQueueTrackAt(queueIndex + 1);
const queueServerId = usePlayerStore(s => s.queueServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
useEffect(() => {
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
nextTrack.albumId,
nextTrack.coverArt,
300,
);
getCachedBlob(url, key).catch(() => {});
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
// 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; Esc closes.
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
const metaParts = useMemo(() => [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean), [currentTrack]);
return (
<div
className="fs-player"
role="dialog"
aria-modal="true"
aria-label={t('player.fullscreen')}
data-idle={isIdle}
data-lyrics={isAppleMode || undefined}
onMouseMove={handleMouseMove}
style={{
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
'--fs-portrait-dim': String(fsPortraitDim / 100),
} as React.CSSProperties}
>
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Album art */}
<div className="fs-art-wrap">
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
</div>
{/* Track title — massive statement */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Artist — secondary, below track */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* 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 controlsAnchorRef={fsControlsRef} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
<button
ref={lyricsMenuTriggerRef}
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
</div>
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+42 -15
View File
@@ -1,4 +1,5 @@
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
import { getRandomAlbums } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
@@ -33,14 +34,25 @@ function HeroBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counter = useRef(1);
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
useEffect(() => {
if (!url) return;
if (!url) {
setLayers([]);
return;
}
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
const t1 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20);
const t2 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
@@ -259,7 +271,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
getAlbum(album.id).then(data => {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
return;
}
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
@@ -273,17 +291,20 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
ensurePriority: 'high',
});
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
// Per-album fallback so a cache miss on the current slide does not flash empty,
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
const stableBgByAlbum = useRef<Record<string, string>>({});
const albumId = album?.id;
useEffect(() => {
if (bgHandle.src && albumId) {
stableBgByAlbum.current[albumId] = bgHandle.src;
}
}, [bgHandle.src, albumId]);
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]);
if (!album) return <div className="hero-placeholder" />;
@@ -296,7 +317,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
@@ -339,7 +360,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async e => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
}}
@@ -369,7 +393,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) {}
+1 -1
View File
@@ -10,7 +10,7 @@ interface Props {
export default function LosslessFilterButton({ active, onChange }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
return (
<button
+16 -4
View File
@@ -4,10 +4,11 @@ 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';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -15,8 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const isServerOffline = offlineCtx.active;
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
@@ -29,6 +32,15 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
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;
if (isServerOffline && !isOfflineSidebarNavAllowed(
cfg.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
+1 -1
View File
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
// Scrubber touch/mouse drag
+2 -2
View File
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
color: 'var(--text-on-accent)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
{stream.albumId && stream.coverArt ? (
<TrackCoverArtImage
song={{
+135
View File
@@ -0,0 +1,135 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../utils/format/formatBytes';
const OPEN_DELAY_MS = 450;
interface Props {
label: string;
totalBytes: number | null;
libraryBytes: number | null;
favoritesBytes: number | null;
}
export function OfflineLibraryDiskStat({
label,
totalBytes,
libraryBytes,
favoritesBytes,
}: Props) {
const { t } = useTranslation();
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const openTimerRef = useRef<number | null>(null);
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
const clearOpenTimer = () => {
if (openTimerRef.current !== null) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
};
const scheduleOpen = () => {
if (!breakdownReady) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
};
const close = () => {
clearOpenTimer();
setOpen(false);
};
const breakdownAriaLabel = breakdownReady
? [
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
].join('. ')
: undefined;
useLayoutEffect(() => {
if (!open || !anchorRef.current || !popoverRef.current) {
setStyle({ opacity: 0 });
return;
}
const anchor = anchorRef.current.getBoundingClientRect();
const box = popoverRef.current.getBoundingClientRect();
const GAP = 8;
const MARGIN = 8;
let top = anchor.bottom + GAP;
let left = anchor.left + anchor.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
setStyle({ opacity: 1, top, left });
}, [open, libraryBytes, favoritesBytes]);
useEffect(() => () => clearOpenTimer(), []);
return (
<>
<div
ref={anchorRef}
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
aria-live="polite"
aria-label={breakdownAriaLabel}
onMouseEnter={scheduleOpen}
onMouseLeave={close}
onFocus={scheduleOpen}
onBlur={close}
tabIndex={breakdownReady ? 0 : undefined}
>
<span className="offline-library-disk-label">{label}</span>
<span className="offline-library-disk-value">
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
</span>
</div>
{open && breakdownReady && createPortal(
<div
ref={popoverRef}
className="offline-library-disk-breakdown"
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
role="tooltip"
onMouseEnter={() => {
clearOpenTimer();
setOpen(true);
}}
onMouseLeave={close}
>
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
aria-hidden
>
<HardDriveDownload size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(libraryBytes)}
</span>
</div>
<div className="offline-library-disk-breakdown-divider" aria-hidden />
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
aria-hidden
>
<Heart size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(favoritesBytes)}
</span>
</div>
</div>,
document.body,
)}
</>
);
}
+24 -8
View File
@@ -66,14 +66,30 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
}, [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' };
let style: React.CSSProperties = { display: 'none' };
if (anchor) {
const vw = window.innerWidth;
// Compact, viewport-bounded width (same scale as the other Orbit popovers).
const width = Math.min(560, vw - 16);
// Anchor under the trigger, but slide inward so the right-anchored popover
// never spills off either window edge — on a narrow window the left edge
// would otherwise drop off-screen.
const right = Math.min(
Math.max(8, vw - anchor.right),
Math.max(8, vw - width - 8),
);
style = {
position: 'fixed',
top: anchor.bottom + 12,
right,
width,
// Cap height to the space below the anchor (minus a small margin); the
// log textarea flex-shrinks to fit. Re-read every render (the 1s
// mini-display tick keeps width/height fresh after a window resize).
maxHeight: `calc(100vh - ${Math.round(anchor.bottom + 24)}px)`,
zIndex: 9999,
};
}
// ── Live mini-display data ────────────────────────────────────────────
const role = useOrbitStore(s => s.role);
+118
View File
@@ -0,0 +1,118 @@
import { StrictMode } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeInitialOrbitState } from '../api/orbit';
const { authState, orbitState, orbitApi } = vi.hoisted(() => ({
authState: { isLoggedIn: true, activeServerId: 'srv-1' as string | null, username: 'bob' as string | undefined },
orbitState: { role: null as 'host' | 'guest' | null },
orbitApi: {
readOrbitLastSession: vi.fn(),
findSessionPlaylistId: vi.fn(),
readOrbitState: vi.fn(),
clearOrbitLastSession: vi.fn(),
resumeOrbitSessionAsHost: vi.fn(),
joinOrbitSession: vi.fn(),
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('../store/authStore', () => {
const hook = (sel: (s: typeof authState) => unknown) => sel(authState);
(hook as unknown as { getState: () => unknown }).getState = () => ({
...authState,
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
});
return { useAuthStore: hook };
});
vi.mock('../store/orbitStore', () => {
const hook = (sel: (s: typeof orbitState) => unknown) => sel(orbitState);
(hook as unknown as { getState: () => unknown }).getState = () => orbitState;
return { useOrbitStore: hook };
});
vi.mock('../utils/ui/toast', () => ({ showToast: vi.fn() }));
vi.mock('../utils/orbit', () => ({
...orbitApi,
ORBIT_RECONNECT_COUNTDOWN_S: 30,
ORBIT_RECONNECT_MAX_AGE_MS: 30 * 60_000,
}));
import OrbitReconnectModal from './OrbitReconnectModal';
const hostBreadcrumb = {
sid: 'feedface',
sessionPlaylistId: 'pl-1',
outboxPlaylistId: 'ob-1',
role: 'host' as const,
sessionName: 'Night Run',
hostUsername: 'bob',
serverId: 'srv-1',
savedAt: 1,
};
beforeEach(() => {
vi.clearAllMocks();
authState.isLoggedIn = true;
authState.activeServerId = 'srv-1';
authState.username = 'bob';
orbitState.role = null;
orbitApi.readOrbitLastSession.mockReturnValue(hostBreadcrumb);
orbitApi.findSessionPlaylistId.mockResolvedValue('pl-1');
orbitApi.readOrbitState.mockResolvedValue(
makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' }),
);
});
describe('OrbitReconnectModal', () => {
// Regression: StrictMode double-invokes effects (mount → cleanup → mount).
// The old one-shot guard let the cancelled first run block the second, so the
// prompt never appeared in dev builds. This must survive the double-invoke.
it('shows the reconnect prompt under StrictMode for a live host session', async () => {
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(orbitApi.clearOrbitLastSession).not.toHaveBeenCalled();
});
it('does not prompt when the breadcrumb is for a different server', async () => {
authState.activeServerId = 'other-server';
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await Promise.resolve();
await Promise.resolve();
expect(screen.queryByRole('dialog')).toBeNull();
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
});
it('wipes the breadcrumb and stays silent when the session is gone', async () => {
orbitApi.findSessionPlaylistId.mockResolvedValue(null);
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await waitFor(() => expect(orbitApi.clearOrbitLastSession).toHaveBeenCalled());
expect(screen.queryByRole('dialog')).toBeNull();
});
it('does not prompt while already bound to a session', async () => {
orbitState.role = 'host';
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await Promise.resolve();
await Promise.resolve();
expect(screen.queryByRole('dialog')).toBeNull();
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { showToast } from '../utils/ui/toast';
import {
clearOrbitLastSession,
findSessionPlaylistId,
joinOrbitSession,
ORBIT_RECONNECT_COUNTDOWN_S,
ORBIT_RECONNECT_MAX_AGE_MS,
readOrbitLastSession,
readOrbitState,
resumeOrbitSessionAsHost,
type OrbitLastSession,
} from '../utils/orbit';
/**
* Orbit reconnect prompt shown at startup when the app was restarted while a
* session was active. The in-memory Orbit store is gone after a restart, but
* the `lastSession` breadcrumb survives; this verifies the session is still
* alive on the server and offers a one-click rejoin with a countdown that
* auto-rejoins when it reaches zero.
*
* Host breadcrumbs resume hosting (`resumeOrbitSessionAsHost`); guest
* breadcrumbs rejoin via the normal `joinOrbitSession` path. Declining /
* Escape / a failed attempt wipes the breadcrumb so it isn't offered again.
*/
export default function OrbitReconnectModal() {
const { t } = useTranslation();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const orbitRole = useOrbitStore(s => s.role);
const [candidate, setCandidate] = useState<OrbitLastSession | null>(null);
const [secondsLeft, setSecondsLeft] = useState(ORBIT_RECONNECT_COUNTDOWN_S);
const [busy, setBusy] = useState(false);
const decidedRef = useRef(false);
const firedRef = useRef(false);
// One-shot startup preflight: is there a live session worth offering?
//
// StrictMode-safe: `decidedRef` is set only *after* the `cancelled` guard
// inside the async, never up front. React's dev double-invoke (mount →
// cleanup → mount) cancels the first run before it can decide, so the second
// (real) mount still runs the check to completion. A network error leaves
// `decidedRef` false so a later dependency change retries.
useEffect(() => {
if (decidedRef.current) return;
if (!isLoggedIn || !activeServerId || orbitRole !== null) return;
const rec = readOrbitLastSession();
if (!rec || rec.serverId !== activeServerId) return; // none / different server
let cancelled = false;
void (async () => {
try {
const sessionPlaylistId = await findSessionPlaylistId(rec.sid);
if (cancelled) return;
if (!sessionPlaylistId) { decidedRef.current = true; clearOrbitLastSession(); return; }
const state = await readOrbitState(sessionPlaylistId);
if (cancelled) return;
if (!state || state.ended) { decidedRef.current = true; clearOrbitLastSession(); return; }
// Too long since the last host snapshot → treat as dead, don't offer.
if (Date.now() - (state.positionAt ?? 0) > ORBIT_RECONNECT_MAX_AGE_MS) {
decidedRef.current = true; clearOrbitLastSession(); return;
}
// A host breadcrumb only resumes if we're still the session's host.
if (rec.role === 'host' && state.host !== useAuthStore.getState().getActiveServer()?.username) {
decidedRef.current = true; clearOrbitLastSession(); return;
}
decidedRef.current = true;
setCandidate(rec);
} catch {
/* network hiccup — keep the breadcrumb + retry on the next dep change */
}
})();
return () => { cancelled = true; };
}, [isLoggedIn, activeServerId, orbitRole]);
const doReconnect = useCallback(async () => {
if (firedRef.current) return; // guard countdown + manual click racing
const rec = candidate;
if (!rec) return;
firedRef.current = true;
setBusy(true);
try {
if (rec.role === 'host') await resumeOrbitSessionAsHost(rec.sid);
else await joinOrbitSession(rec.sid);
setCandidate(null); // success → store now bound, modal hides
} catch {
clearOrbitLastSession();
showToast(t('orbit.reconnect.failed'), 4000, 'error');
setCandidate(null);
} finally {
setBusy(false);
}
}, [candidate, t]);
const stayOut = useCallback(() => {
clearOrbitLastSession();
setCandidate(null);
}, []);
// Countdown → auto-rejoin at zero. Paused while a reconnect is in flight.
useEffect(() => {
if (!candidate || busy) return;
if (secondsLeft <= 0) { void doReconnect(); return; }
const id = window.setTimeout(() => setSecondsLeft(s => s - 1), 1000);
return () => window.clearTimeout(id);
}, [candidate, busy, secondsLeft, doReconnect]);
// Enter → rejoin now; Escape → stay out (explicit dismissal, no auto-rejoin).
useEffect(() => {
if (!candidate) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); void doReconnect(); }
else if (e.key === 'Escape') { e.preventDefault(); stayOut(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [candidate, doReconnect, stayOut]);
if (!candidate) return null;
const body = candidate.role === 'host'
? t('orbit.reconnect.bodyHost', { name: candidate.sessionName })
: t('orbit.reconnect.bodyGuest', { host: candidate.hostUsername, name: candidate.sessionName });
return createPortal(
<div
className="modal-overlay orbit-exit-overlay"
onClick={e => { if (e.target === e.currentTarget) stayOut(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-reconnect-title"
>
<div className="modal-content orbit-exit-modal">
<h3 id="orbit-reconnect-title" className="orbit-exit-modal__title">{t('orbit.reconnect.title')}</h3>
<p className="orbit-exit-modal__body">{body}</p>
{!busy && (
<p className="orbit-reconnect-countdown">{t('orbit.reconnect.autoIn', { seconds: secondsLeft })}</p>
)}
<div className="orbit-exit-modal__actions">
<button type="button" className="btn btn-ghost" onClick={stayOut} disabled={busy}>
{t('orbit.reconnect.stayOut')}
</button>
<button type="button" className="btn btn-primary" onClick={() => void doReconnect()} disabled={busy} autoFocus>
{busy ? t('orbit.reconnect.reconnecting') : t('orbit.reconnect.rejoin')}
</button>
</div>
</div>
</div>,
document.body,
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export default function PSmallLogo({ className, style }: Props) {
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--ctp-blue)" />
<stop offset="100%" stopColor="var(--accent-2)" />
</linearGradient>
</defs><g
id="layer1"
+7
View File
@@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
pingWithCredentials: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
+2 -3
View File
@@ -15,7 +15,6 @@ import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import StarRating from './StarRating';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useLyricsStore } from '../store/lyricsStore';
@@ -136,7 +135,7 @@ export default function PlayerBar() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
@@ -180,7 +179,7 @@ export default function PlayerBar() {
}, [volume, setVolume, utilityOverflow]);
const volumeStyle = {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
};
const playerBarContent = (
+1 -1
View File
@@ -19,7 +19,7 @@ export default function PsysonicLogo({ className, style, gradientIdSuffix }: Pro
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
<stop offset="100%" stopColor="var(--logo-color-end, var(--accent-2))" />
</linearGradient>
</defs><g
id="layer1"
+5 -5
View File
@@ -156,16 +156,16 @@ describe('QueuePanel — display mode', () => {
expect(container.textContent).toContain('No upcoming tracks');
});
it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => {
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
const { container } = renderWithProviders(<QueuePanel />);
// The mode toggle is the first .queue-action-btn in the header (the
// collapse chevron is the second). The default mode is 'queue', so the
// toggle's label names its target ("Playlist").
// collapse chevron is the second). The toggle's label names its target;
// from the default 'queue' that is the next mode in the cycle, "Timeline".
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
expect(toggle?.getAttribute('aria-label')).toBe('Playlist');
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
toggle!.click();
expect(useAuthStore.getState().queueDisplayMode).toBe('playlist');
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
});
});
+13 -7
View File
@@ -1,5 +1,6 @@
import { Play } from 'lucide-react';
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { updatePlaylist } from '../api/subsonicPlaylists';
import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import { songToTrack } from '../utils/playback/songToTrack';
import type { Track } from '../store/playerStoreTypes';
import { useState, useRef, useMemo } from 'react';
@@ -33,6 +34,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
import { QueueList } from './queuePanel/QueueList';
import { QueueTabBar } from './queuePanel/QueueTabBar';
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -170,11 +172,12 @@ function QueuePanelHostOrSolo() {
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = async () => {
if (queueItems.length === 0) return;
const exportTrackIds = activeServerQueueTrackIds(queueItems);
if (exportTrackIds.length === 0) return;
if (activePlaylist) {
setSaveState('saving');
try {
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
await updatePlaylist(activePlaylist.id, exportTrackIds);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500);
} catch (e) {
@@ -196,7 +199,8 @@ function QueuePanelHostOrSolo() {
};
const handleCopyQueueShare = async () => {
if (queueItems.length === 0) {
const ids = activeServerQueueTrackIds(queueItems);
if (ids.length === 0) {
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
return;
}
@@ -207,7 +211,6 @@ function QueuePanelHostOrSolo() {
if (!active) return;
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = queueItems.map(r => r.trackId);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
@@ -381,7 +384,7 @@ function QueuePanelHostOrSolo() {
onSave={async (name) => {
try {
const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false);
} catch (e) {
@@ -396,7 +399,10 @@ function QueuePanelHostOrSolo() {
onClose={() => setLoadModalOpen(false)}
onLoad={async (id, name, mode) => {
try {
const data = await getPlaylist(id);
const serverId = resolveMediaServerId();
if (!serverId) return;
const data = await resolvePlaylist(serverId, id);
if (!data) return;
const tracks: Track[] = data.songs.map(songToTrack);
if (tracks.length > 0) {
if (mode === 'append') {
+49 -9
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
@@ -23,7 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
import SidebarNavBody from './sidebar/SidebarNavBody';
@@ -41,15 +43,24 @@ export default function Sidebar({
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineJobStore(s => s.jobs);
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
const pinQueue = useOfflineJobStore(s => s.pinQueue);
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const activePin = pinQueue.find(p => p.status === 'downloading')
?? pinQueue.find(p => p.status === 'queued');
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
const cancelAllDownloads = () => {
clearOfflinePinTasks();
cancelAllDownloadsStore();
};
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
const syncJobDone = useDeviceSyncJobStore(s => s.done);
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
const isSyncing = syncJobStatus === 'running';
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const musicFolders = useAuthStore(s => s.musicFolders);
@@ -61,7 +72,8 @@ export default function Sidebar({
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const isServerOffline = offlineCtx.active;
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
@@ -82,7 +94,7 @@ export default function Sidebar({
}, [playlistsRaw]);
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
@@ -101,13 +113,34 @@ export default function Sidebar({
libraryItemsForReorder.filter(c => {
if (!c.visible) return false;
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[libraryItemsForReorder, luckyMixAvailable],
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible),
[systemItemsForReorder],
() => systemItemsForReorder.filter(c => {
if (!c.visible) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[systemItemsForReorder, isServerOffline, offlineNav],
);
const sidebarItemsRef = useRef(sidebarItems);
@@ -140,10 +173,15 @@ export default function Sidebar({
const pickLibrary = (id: 'all' | string) => {
if (isServerOffline) return;
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
useEffect(() => {
if (isServerOffline) setLibraryDropdownOpen(false);
}, [isServerOffline, setLibraryDropdownOpen]);
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
@@ -231,6 +269,8 @@ export default function Sidebar({
nowPlayingAtTop={nowPlayingAtTop}
hasOfflineContent={hasOfflineContent}
activeJobsCount={activeJobs.length}
activePinName={activePin?.albumName ?? null}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
+1 -1
View File
@@ -14,7 +14,7 @@ interface Props {
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
if (size === 'compact') {
return (
+40
View File
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import ConfirmModal from './ConfirmModal';
import { readThemeMigrationNotice, clearThemeMigrationNotice } from '../utils/themes/themeMigration';
/**
* One-time, dismissible notice shown after the slim-bundle migration reset a
* theme that is now store-only or retired. Reads the flag written by
* `migrateThemeSelection` at startup, points the user at the Theme Store, and
* clears the flag on dismiss so it never shows again.
*/
export default function ThemeMigrationNotice() {
const { t } = useTranslation();
const navigate = useNavigate();
const [themes] = useState(() => readThemeMigrationNotice());
const [open, setOpen] = useState(themes.length > 0);
if (!open) return null;
const close = () => {
clearThemeMigrationNotice();
setOpen(false);
};
return (
<ConfirmModal
open={open}
title={t('settings.themeMigrationNoticeTitle')}
message={t('settings.themeMigrationNoticeBody', { themes: themes.join(', ') })}
confirmLabel={t('settings.themeMigrationNoticeOpen')}
cancelLabel={t('common.close')}
onConfirm={() => {
close();
navigate('/settings', { state: { tab: 'themes' } });
}}
onCancel={close}
/>
);
}
-271
View File
@@ -1,271 +0,0 @@
import { Fragment, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
interface ThemeDef {
id: string;
label: string;
bg: string;
card: string;
accent: string;
family?: string;
}
export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Games',
themes: [
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#06060f', card: '#0a0a1a', accent: '#FCEE0A' },
{ id: 'tetrastack', label: 'TetraStack', bg: '#060614', card: '#0c0c20', accent: '#00f0f0' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
],
},
{
group: 'Movies',
themes: [
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'dune', label: 'Dune', bg: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 't-800', label: 'T-800', bg: '#140e0e', card: '#1a0a0a', accent: '#ff2000' },
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
],
},
{
group: 'Open Source Classics',
themes: [
// ── 1984 (juanmnl/vs-1984 — only variants with a distinct palette)
{ id: 'vs-1984', label: 'Default', bg: '#0d0f31', card: '#161a4a', accent: '#46BDFF', family: '1984' },
{ id: 'vs-1984-cyberpunk', label: 'Cyberpunk', bg: '#1C1E27', card: '#232631', accent: '#85EEA7', family: '1984' },
{ id: 'vs-1984-light', label: 'Light', bg: '#e4e5f5', card: '#d4d6e7', accent: '#4d5eff', family: '1984' },
{ id: 'vs-1984-orwell', label: 'Orwell', bg: '#2e2923', card: '#3a342c', accent: '#fcd395', family: '1984' },
// ── Atom One (Th3Whit3Wolf/one-nvim — Atom One Dark / Light derivative)
{ id: 'one-dark', label: 'Dark', bg: '#282c34', card: '#2c323c', accent: '#61afef', family: 'Atom One' },
{ id: 'one-light', label: 'Light', bg: '#fafafa', card: '#ececed', accent: '#4078f2', family: 'Atom One' },
// ── Catppuccin (dark → light)
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7', family: 'Catppuccin' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6', family: 'Catppuccin' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6', family: 'Catppuccin' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef', family: 'Catppuccin' },
// ── Dracula
{ id: 'dracula', label: 'Dracula', bg: '#282a36', card: '#44475a', accent: '#bd93f9', family: 'Dracula' },
// ── Gruvbox (dark → light, hard → soft)
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f', family: 'Gruvbox' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f', family: 'Gruvbox' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f', family: 'Gruvbox' },
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614', family: 'Gruvbox' },
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614', family: 'Gruvbox' },
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614', family: 'Gruvbox' },
// ── Kanagawa (default → variants)
{ id: 'kanagawa-wave', label: 'Wave', bg: '#1F1F28', card: '#2A2A37', accent: '#7E9CD8', family: 'Kanagawa' },
{ id: 'kanagawa-dragon', label: 'Dragon', bg: '#181616', card: '#282727', accent: '#8ba4b0', family: 'Kanagawa' },
{ id: 'kanagawa-lotus', label: 'Lotus', bg: '#f2ecbc', card: '#d5cea3', accent: '#4d699b', family: 'Kanagawa' },
// ── Nightfox (default → variants, alphabetical)
{ id: 'nightfox', label: 'Nightfox', bg: '#192330', card: '#212e3f', accent: '#719cd6', family: 'Nightfox' },
{ id: 'carbonfox', label: 'Carbonfox', bg: '#161616', card: '#1c1c1c', accent: '#be95ff', family: 'Nightfox' },
{ id: 'dawnfox', label: 'Dawnfox', bg: '#faf4ed', card: '#ebe0df', accent: '#907aa9', family: 'Nightfox' },
{ id: 'dayfox', label: 'Dayfox', bg: '#f6f2ee', card: '#dbd1dd', accent: '#2848a9', family: 'Nightfox' },
{ id: 'duskfox', label: 'Duskfox', bg: '#232136', card: '#2d2a45', accent: '#c4a7e7', family: 'Nightfox' },
{ id: 'nordfox', label: 'Nordfox', bg: '#2e3440', card: '#39404f', accent: '#81a1c1', family: 'Nightfox' },
{ id: 'terafox', label: 'Terafox', bg: '#152528', card: '#1d3337', accent: '#a1cdd8', family: 'Nightfox' },
// ── Nord (Polar Night → Snowstorm → Frost → Aurora, official ordering)
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0', family: 'Nord' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac', family: 'Nord' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0', family: 'Nord' },
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead', family: 'Nord' },
],
},
{
group: 'Operating Systems',
themes: [
{ id: 'ubuntu-ambiance', label: 'Ubuntu', bg: '#f4efea', card: '#3d1f3d', accent: '#e95420' },
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
{ id: 'wista', label: 'Wista', bg: '#eef3fc', card: '#0e1e3e', accent: '#1565c8' },
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
{ id: 'w10', label: 'W10', bg: '#f3f3f3', card: '#ffffff', accent: '#0078d4' },
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
],
},
{
group: 'Psysonic Themes',
themes: [
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
],
},
{
group: 'COMMUNITY',
themes: [
{ id: 'amoled-black-pure', label: 'AMOLED Black Pure', bg: '#000000', card: '#050505', accent: '#ffffff' },
{ id: 'obsidian-black', label: 'Obsidian Black', bg: '#070706', card: '#11110f', accent: '#dfddd8' },
{ id: 'carbon-grey', label: 'Carbon Grey', bg: '#0f0f10', card: '#18181b', accent: '#b4bcc8' },
{ id: 'midnight-blue', label: 'Midnight Blue', bg: '#0d1420', card: '#111a28', accent: '#60a5fa' },
{ id: 'sepia-dark', label: 'Sepia Dark', bg: '#1e1a14', card: '#252018', accent: '#c8b89a' },
{ id: 'volcanic-dark', label: 'Volcanic Dark', bg: '#1b1512', card: '#231b17', accent: '#c97a56' },
{ id: 'deep-forest', label: 'Deep Forest', bg: '#141c17', card: '#19231d', accent: '#6aa37c' },
{ id: 'violet-haze', label: 'Violet Haze', bg: '#12101d', card: '#171425', accent: '#b4a0ff' },
{ id: 'copper-oxide', label: 'Copper Oxide', bg: '#101a18', card: '#131f1c', accent: '#45b8b0' },
{ id: 'sakura-night', label: 'Sakura Night', bg: '#1c1218', card: '#241821', accent: '#d8a6bc' },
{ id: 'obsidian-gold', label: 'Obsidian Gold', bg: '#0f0d09', card: '#15120d', accent: '#c9a227' }
],
},
{
group: 'Mediaplayer',
themes: [
{ id: 'winmedplayer', label: 'WinMedPlayer', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#d4cc46' },
],
},
{
group: 'Series',
themes: [
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#100c08', card: '#090c10', accent: '#c41e1e' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
{ id: 'north-park', label: 'North Park', bg: '#F5F1E8', card: '#FFFFFF', accent: '#FF8C00' },
],
},
{
group: 'Famous Albums',
themes: [
{ id: 'dark-side-of-the-moon', label: 'Dark Side of the Moon (inspired)', bg: '#050505', card: '#0D0D0D', accent: '#9B30FF' },
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
],
},
{
group: 'Accessibility',
themes: [
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700' },
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700' },
],
},
{
group: 'Social Media',
themes: [
{ id: 'insta', label: 'Insta', bg: '#121212', card: '#000000', accent: '#E1306C' },
{ id: 'readit', label: 'ReadIt', bg: '#030303', card: '#1A1A1B', accent: '#FF4500' },
{ id: 'the-book', label: 'The Book', bg: '#F0F2F5', card: '#FFFFFF', accent: '#1877F2' },
],
},
];
interface Props {
value: string;
onChange: (id: string) => void;
}
export default function ThemePicker({ value, onChange }: Props) {
// All groups collapsed on first render. The blue dot in the header surfaces
// which group holds the active theme, so auto-expanding it on mount just
// adds visual noise to a screen that already has a long sub-section list.
const [openGroup, setOpenGroup] = useState<string | null>(null);
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
return (
<div className="theme-accordion">
{THEME_GROUPS.map(({ group, themes }) => {
const isOpen = openGroup === group;
const hasActive = themes.some(t => t.id === value);
return (
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
<button className="theme-accordion-header" onClick={() => toggle(group)}>
<span>
{group}
{hasActive && !isOpen && (
<span className="theme-accordion-active-dot" />
)}
</span>
<ChevronDown size={15} className="theme-accordion-chevron" />
</button>
{isOpen && (
<div className="theme-accordion-content">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{(() => {
let lastFamily: string | undefined;
return themes.map((t, i) => {
const isActive = value === t.id;
const showFamilyHeader = !!t.family && t.family !== lastFamily;
lastFamily = t.family;
return (
<Fragment key={t.id}>
{showFamilyHeader && (
<div
className="theme-family-header"
style={{ gridColumn: '1 / -1', marginTop: i === 0 ? 0 : 10 }}
>
{t.family}
</div>
)}
<button
className="theme-card-btn"
onClick={() => onChange(t.id)}
>
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
{t.label}
</span>
</button>
</Fragment>
);
});
})()}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
+21
View File
@@ -219,6 +219,27 @@ export default function WaveformSeek({ trackId }: Props) {
return () => observer.disconnect();
}, [seekbarStyle]);
// Dev-only: a theme's CSS variables can change via Vite HMR without the
// `data-theme` attribute changing, so the observer above never fires and the
// cached colours + canvas keep the old palette until a manual theme switch
// (annoying during theme dev — issue: waveform doesn't reflect CSS var edits).
// On every HMR update, drop the colour cache and repaint. `import.meta.hot` is
// undefined in production builds, so this whole effect is stripped there.
useEffect(() => {
// `import.meta.hot` is undefined in prod builds and only a partial stub in
// the test/SSR env (has `.on` but not `.off`), so feature-detect both before
// wiring — otherwise the cleanup throws under vitest.
const hot = import.meta.hot;
if (!hot || typeof hot.on !== 'function' || typeof hot.off !== 'function') return;
const repaint = () => {
invalidateColorCache();
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
};
hot.on('vite:afterUpdate', repaint);
return () => { hot.off('vite:afterUpdate', repaint); };
}, [seekbarStyle]);
const trackIdRef = useRef(trackId);
trackIdRef.current = trackId;
const seekRef = useRef(seek);
+2 -2
View File
@@ -59,11 +59,11 @@ export function SeekbarPreview({ style, label, selected, onClick }: Props) {
<button
onClick={onClick}
style={{
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
borderRadius: 8,
background: selected
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: 'var(--bg-card, var(--ctp-base))',
: 'var(--bg-card, var(--bg-app))',
padding: '10px 12px 8px',
cursor: 'pointer',
width: 130,
+1 -1
View File
@@ -140,7 +140,7 @@ export default function YearFilterButton({
{...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
...(active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}),
}}
>
<CalendarRange size={14} />
@@ -3,6 +3,7 @@ import { ListPlus, Search, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { useSelectionStore } from '../../store/selectionStore';
import { AddToPlaylistSubmenu } from '../ContextMenu';
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
interface Props {
filterText: string;
@@ -12,6 +13,7 @@ interface Props {
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
t: TFunction;
actionPolicy?: OfflineActionPolicy;
}
/**
@@ -31,7 +33,9 @@ export function AlbumDetailToolbar({
showPlPicker,
setShowPlPicker,
t,
actionPolicy,
}: Props) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
return (
<div className="album-track-toolbar">
<div className="album-track-toolbar-filter">
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
{policy.canAddToPlaylist && (
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
+7 -2
View File
@@ -8,12 +8,13 @@ import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../../utils/playback/songToTrack';
import { useSelectionStore } from '../../store/selectionStore';
import { useThemeStore } from '../../store/themeStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import StarRating from '../StarRating';
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
import { formatLongDuration } from '../../utils/format/formatDuration';
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import i18n from '../../i18n';
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
type ContextMenuFn = (
x: number,
@@ -41,6 +42,7 @@ interface TrackRowProps {
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
setContextMenuSongId: (id: string | null) => void;
actionPolicy?: OfflineActionPolicy;
}
/**
@@ -67,7 +69,9 @@ export const TrackRow = React.memo(function TrackRow({
onToggleSelect,
onDragStart,
setContextMenuSongId,
actionPolicy,
}: TrackRowProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const navigate = useNavigate();
const showBitrate = useThemeStore(s => s.showBitrate);
@@ -116,7 +120,7 @@ export const TrackRow = React.memo(function TrackRow({
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums');
}}
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
@@ -171,6 +175,7 @@ export const TrackRow = React.memo(function TrackRow({
key="rating"
value={ratingValue}
onChange={r => onRate(song.id, r)}
disabled={!policy.canRate}
/>
);
case 'duration':
@@ -7,8 +7,8 @@ import {
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
import { useOfflineStore } from '../../store/offlineStore';
import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useAuthStore } from '../../store/authStore';
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
import { useIsMobile } from '../../hooks/useIsMobile';
import { ArtistHeroCover } from '../../cover/artistHero';
import { useCoverLightboxSrc } from '../../cover/lightbox';
@@ -16,6 +16,7 @@ import type { CoverArtRef } from '../../cover/types';
import LastfmIcon from '../LastfmIcon';
import StarRating from '../StarRating';
import { tooltipAttrs } from '../tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
interface Props {
artist: SubsonicArtist;
@@ -41,6 +42,7 @@ interface Props {
coverRevision: number;
headerCoverFailed: boolean;
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
actionPolicy?: OfflineActionPolicy;
}
export default function ArtistDetailHero({
@@ -49,14 +51,21 @@ export default function ArtistDetailHero({
handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink,
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
actionPolicy,
}: Props) {
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
const { t } = useTranslation();
const goBack = useAlbumDetailBack();
const isMobile = useIsMobile();
const imageInputRef = useRef<HTMLInputElement>(null);
const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const artistAlbumIds = useMemo(() => albums.map(a => a.id), [albums]);
const { status: artistOfflineStatus, progress: artistOfflineProgress } = useArtistOfflineState(
id ?? '',
activeServerId,
artistAlbumIds,
);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
@@ -134,7 +143,7 @@ export default function ArtistDetailHero({
<StarRating
value={artistEntityRating}
onChange={handleArtistEntityRating}
disabled={artistEntityRatingSupport === 'track_only'}
disabled={!policy.canRate || artistEntityRatingSupport === 'track_only'}
labelKey="entityRating.artistAriaLabel"
/>
</div>
@@ -163,15 +172,17 @@ export default function ArtistDetailHero({
</div>
)}
<button
className="artist-ext-link"
onClick={toggleStar}
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
{t('artistDetail.favorite')}
</button>
{policy.canFavorite && (
<button
className="artist-ext-link"
onClick={toggleStar}
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
{t('artistDetail.favorite')}
</button>
)}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
@@ -217,28 +228,51 @@ export default function ArtistDetailHero({
<Share2 size={16} />
</button>
)}
{albums.length > 0 && (() => {
const progress = id ? bulkProgress[id] : undefined;
const isDone = progress && progress.done === progress.total;
const isDownloading = progress && !isDone;
return (
<button
className="btn btn-surface"
disabled={!!isDownloading}
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
data-tooltip={isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
{!isMobile && (isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
</button>
);
})()}
{policy.canCacheDiscography && albums.length > 0 && (
<button
className="btn btn-surface"
disabled={
artistOfflineStatus === 'downloading'
|| artistOfflineStatus === 'queued'
|| artistOfflineStatus === 'cached'
}
onClick={() => {
if (id && artist && artistOfflineStatus !== 'cached') {
downloadArtist(id, artist.name, activeServerId);
}
}}
data-tooltip={
artistOfflineStatus === 'downloading' && artistOfflineProgress
? t('artistDetail.offlineDownloading', {
done: artistOfflineProgress.done,
total: artistOfflineProgress.total,
})
: artistOfflineStatus === 'queued'
? t('artistDetail.offlineQueued')
: artistOfflineStatus === 'cached'
? t('artistDetail.offlineCached')
: t('artistDetail.cacheOffline')
}
>
{artistOfflineStatus === 'downloading'
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: artistOfflineStatus === 'cached'
? <Check size={16} />
: <HardDriveDownload size={16} />}
{!isMobile && (
artistOfflineStatus === 'downloading' && artistOfflineProgress
? t('artistDetail.offlineDownloading', {
done: artistOfflineProgress.done,
total: artistOfflineProgress.total,
})
: artistOfflineStatus === 'queued'
? t('artistDetail.offlineQueued')
: artistOfflineStatus === 'cached'
? t('artistDetail.offlineCached')
: t('artistDetail.cacheOffline')
)}
</button>
)}
</div>
</div>
</div>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration';
@@ -82,7 +82,7 @@ export default function ArtistDetailTopTracks({
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview(previewInputFromSong(song), 'artist'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { getAlbum } from '../../api/subsonicLibrary';
import { getArtist } from '../../api/subsonicArtists';
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
interface AlbumProps {
@@ -13,8 +12,13 @@ export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProp
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
useEffect(() => {
getAlbum(albumId).then((data) => {
setResolvedIds(data.songs.map((s) => s.id));
const serverId = resolveMediaServerId();
if (!serverId) {
setResolvedIds([]);
return;
}
resolveAlbum(serverId, albumId).then((data) => {
setResolvedIds(data ? data.songs.map((s) => s.id) : []);
}).catch(() => setResolvedIds([]));
}, [albumId]);
@@ -40,8 +44,19 @@ export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistP
useEffect(() => {
(async () => {
const { albums } = await getArtist(artistId);
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs)));
const serverId = resolveMediaServerId();
if (!serverId) {
setResolvedIds([]);
return;
}
const artistData = await resolveArtist(serverId, artistId);
if (!artistData) {
setResolvedIds([]);
return;
}
const albumSongs = await Promise.all(
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? [])),
);
setResolvedIds(albumSongs.flat().map(s => s.id));
})().catch(() => setResolvedIds([]));
}, [artistId]);
+112 -88
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { getAlbum } from '../../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
import { star, unstar } from '../../api/subsonicStarRating';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
@@ -22,7 +22,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
pinToPlaybackServer, navigateLibrary,
pinToPlaybackServer, navigateLibrary, offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -40,7 +40,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
if (tracks.length === 0) return;
playNext(tracks);
@@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
enqueue(albumData.songs.map(songToTrack));
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
@@ -57,57 +63,66 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
const meta = {
name: album.name,
artist: album.artist,
artistId: album.artistId,
coverArtId: album.coverArt,
year: album.year,
};
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
})}>
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div>
<div
className="context-menu-rating-row"
data-rating-kind="album"
data-rating-id={album.id}
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
? keyboardRating.value
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
disabled={albumRatingDisabled}
labelKey="entityRating.albumAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
/>
</div>
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
const meta = {
serverId: album.serverId,
name: album.name,
artist: album.artist,
artistId: album.artistId,
coverArtId: album.coverArt,
year: album.year,
};
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
})}>
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div>
)}
{offlinePolicy.canRate && (
<div
className="context-menu-rating-row"
data-rating-kind="album"
data-rating-id={album.id}
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
? keyboardRating.value
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
disabled={albumRatingDisabled}
labelKey="entityRating.albumAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
/>
</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>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`album:${album.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canDownload && (
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
)}
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`album:${album.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
</>
);
})()}
@@ -130,47 +145,56 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
</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));
const results = await Promise.all(albums.map(async a => {
const serverId = resolveMediaServerId(a.serverId);
if (!serverId) return null;
return resolveAlbum(serverId, a.id);
}));
const allTracks = results
.filter((r): r is NonNullable<typeof r> => r != null)
.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(',')}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div
className="context-menu-rating-row"
data-rating-kind="album"
data-rating-id={multiAlbumRatingId}
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
? keyboardRating.value
: unifiedAlbumRating
}
disabled={albumRatingDisabled}
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
onChange={r => {
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
for (const a of albums) applyAlbumRating(a, r);
}}
/>
</div>
{offlinePolicy.canAddToPlaylist && (
<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(',')}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
{offlinePolicy.canRate && (
<div
className="context-menu-rating-row"
data-rating-kind="album"
data-rating-id={multiAlbumRatingId}
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
? keyboardRating.value
: unifiedAlbumRating
}
disabled={albumRatingDisabled}
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
onChange={r => {
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
for (const a of albums) applyAlbumRating(a, r);
}}
/>
</div>
)}
</>
);
})()}
@@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -35,50 +36,64 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`artist:${artist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`artist:${artist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? '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);
setStarredOverride(artist.id, !starred);
const meta = { name: artist.name, albumCount: artist.albumCount };
return starred
? unstar(artist.id, 'artist', meta)
: star(artist.id, 'artist', meta);
})}>
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
<div
className="context-menu-rating-row"
data-rating-kind="artist"
data-rating-id={artist.id}
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
? keyboardRating.value
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
disabled={artistRatingDisabled}
labelKey="entityRating.artistAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
/>
</div>
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
<>
<div className="context-menu-divider" />
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
return starred
? unstar(artist.id, 'artist', meta)
: star(artist.id, 'artist', meta);
})}>
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
)}
{offlinePolicy.canRate && (
<div
className="context-menu-rating-row"
data-rating-kind="artist"
data-rating-id={artist.id}
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
? keyboardRating.value
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
disabled={artistRatingDisabled}
labelKey="entityRating.artistAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
/>
</div>
)}
</>
)}
</>
);
})()}
@@ -100,40 +115,44 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.selectedArtists', { count: artists.length })}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div
className="context-menu-rating-row"
data-rating-kind="artist"
data-rating-id={multiArtistRatingId}
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
? keyboardRating.value
: unifiedArtistRating
}
disabled={artistRatingDisabled}
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
onChange={r => {
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
for (const a of artists) applyArtistRating(a, r);
}}
/>
</div>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
{offlinePolicy.canRate && (
<div
className="context-menu-rating-row"
data-rating-kind="artist"
data-rating-id={multiArtistRatingId}
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
? keyboardRating.value
: unifiedArtistRating
}
disabled={artistRatingDisabled}
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
onChange={r => {
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
for (const a of artists) applyArtistRating(a, r);
}}
/>
</div>
)}
</>
);
})()}
@@ -1,7 +1,7 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { getAlbum } from '../../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
import { showToast } from '../../utils/ui/toast';
@@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
setTotalAlbums(albumIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
const serverId = resolveMediaServerId();
const albumSongs = serverId
? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => [])))
: [];
const allSongs = albumSongs.flat();
setResolvedIds(allSongs.map(s => s.id));
})().catch(() => setResolvedIds([]));
@@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
}, [albumIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: existingSongs } = await getPlaylist(pl.id);
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
@@ -1,8 +1,7 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { getAlbum } from '../../api/subsonicLibrary';
import { getArtist } from '../../api/subsonicArtists';
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
import { getPlaylists } from '../../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
@@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
const allSongs: string[] = [];
const serverId = resolveMediaServerId();
if (!serverId) {
setResolvedIds([]);
return;
}
for (const artistId of artistIds) {
try {
const { albums } = await getArtist(artistId);
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
const artistData = await resolveArtist(serverId, artistId);
if (!artistData) continue;
const albumSongs = await Promise.all(
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])),
);
allSongs.push(...albumSongs.flat().map(s => s.id));
} catch {
// Skip failed artists
@@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
}, [artistIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: existingSongs } = await getPlaylist(pl.id);
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
@@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`playlist:${playlist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`playlist:${playlist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
{offlinePolicy.canEditPlaylist && (
<>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../../utils/ui/toast');
@@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
})}>
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
</div>
</>
)}
</>
);
})()}
@@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
{offlinePolicy.canEditPlaylist && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../../utils/ui/toast');
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
@@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
})}>
<Trash2 size={14} /> {t('playlists.deleteSelected')}
</div>
)}
</>
);
})()}
@@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred));
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
+95 -73
View File
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
import { getAlbum } from '../../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
import { queueSongStar } from '../../store/pendingStarSync';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
import type { Track } from '../../store/playerStoreTypes';
@@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<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}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const serverId = resolveMediaServerId(song.serverId);
if (!serverId || !song.albumId) return;
const albumData = await resolveAlbum(serverId, song.albumId);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
enqueue(tracks);
})}>
@@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred));
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
)}
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
@@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
);
})()}
<div
className="context-menu-rating-row"
data-rating-kind="song"
data-rating-id={song.id}
data-rating-disabled="false"
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
? keyboardRating.value
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
{offlinePolicy.canRate && (
<div
className="context-menu-rating-row"
data-rating-kind="song"
data-rating-id={song.id}
data-rating-disabled="false"
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
? keyboardRating.value
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
@@ -164,13 +174,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
{playlistId && playlistSongIndex !== undefined && (
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
const { showToast } = await import('../../utils/ui/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs } = await getPlaylist(playlistId);
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, playlistId);
if (!resolved) return;
const { songs } = resolved;
const prevCount = songs.length;
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
await updatePlaylist(playlistId, updatedIds, prevCount);
@@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<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}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
)}
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
@@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
);
})()}
<div
className="context-menu-rating-row"
data-rating-kind="song"
data-rating-id={song.id}
data-rating-disabled="false"
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
? keyboardRating.value
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
{offlinePolicy.canRate && (
<div
className="context-menu-rating-row"
data-rating-kind="song"
data-rating-id={song.id}
data-rating-disabled="false"
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
? keyboardRating.value
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
ariaLabel={t('albumDetail.ratingLabel')}
/>
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
@@ -301,12 +319,16 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
queueSongStar(song.id, false);
})}>
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
</div>
{offlinePolicy.canFavorite && (
<>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
queueSongStar(song.id, false, song.serverId);
})}>
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
</div>
</>
)}
</>
);
})()}
@@ -2,6 +2,7 @@ import type React from 'react';
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import type { EntityShareKind } from '../../utils/share/shareLink';
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
export type RatingKind = 'song' | 'album' | 'artist';
@@ -56,4 +57,5 @@ export interface ContextMenuItemsProps {
/** When true, album/artist links switch to the queue server before routing. */
pinToPlaybackServer: boolean;
navigateLibrary: (path: string) => void | Promise<void>;
offlinePolicy: OfflineActionPolicy;
}
+4 -4
View File
@@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks {
startPreview: (song: SubsonicSong) => void;
rate: (songId: string, rating: number) => void;
remove: (songId: string) => void;
navArtist: (artistId: string) => void;
navAlbum: (albumId: string) => void;
navArtist: (artistId: string, serverId?: string) => void;
navAlbum: (albumId: string, serverId?: string) => void;
}
interface Props {
@@ -100,12 +100,12 @@ function FavoriteSongRow({
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist}</span>
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album}</span>
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{song.album}</span>
</div>
);
case 'genre': return (
@@ -0,0 +1,74 @@
import { HardDrive } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
import {
useFavoritesOfflineStatus,
type FavoritesOfflineSemaphore,
} from '../../hooks/useFavoritesOfflineStatus';
import {
disableFavoritesOfflineSync,
scheduleFavoritesOfflineSync,
} from '../../utils/offline/favoritesOfflineSync';
function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string {
switch (semaphore) {
case 'red':
return 'favorites.offlineSemaphoreError';
case 'yellow':
return 'favorites.offlineSemaphoreSyncing';
case 'green':
return 'favorites.offlineSemaphoreSynced';
}
}
export default function FavoritesOfflineHeader() {
const { t } = useTranslation();
const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled);
const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus();
const semaphoreLabel = semaphore
? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount })
: undefined;
return (
<div className="favorites-offline-control">
{enabled && semaphore && (
<span
className={`favorites-offline-led favorites-offline-led--${semaphore}`}
role="status"
aria-live="polite"
aria-label={semaphoreLabel}
data-tooltip={semaphoreLabel}
data-tooltip-pos="bottom"
/>
)}
<div
className="favorites-offline-toggle"
data-tooltip={t('favorites.offlineTooltip')}
data-tooltip-pos="bottom"
>
<HardDrive
size={16}
className={`favorites-offline-disk-icon${enabled ? ' favorites-offline-disk-icon--on' : ''}`}
aria-hidden
/>
<label className="toggle-switch" aria-label={t('favorites.offlineTooltip')}>
<input
type="checkbox"
checked={enabled}
onChange={async e => {
const next = e.target.checked;
if (!next) {
await disableFavoritesOfflineSync();
} else {
setEnabled(true);
scheduleFavoritesOfflineSync();
}
}}
/>
<span className="toggle-track" />
</label>
</div>
</div>
);
}
@@ -7,12 +7,13 @@ import { useNavigate } from 'react-router-dom';
import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useSelectionStore } from '../../store/selectionStore';
import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack';
import { appendServerQuery } from '../../utils/navigation/detailServerScope';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import { useElementClientHeightById } from '../../hooks/useResizeClientHeight';
import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering';
@@ -122,13 +123,19 @@ export default function FavoritesSongsTracklist({
L.playTrack(L.visibleTracks[index], L.visibleTracks);
},
startPreview: (song) => usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
previewInputFromSong(song),
'favorites',
),
rate: (songId, r) => latest.current.handleRate(songId, r),
remove: (songId) => latest.current.removeSong(songId),
navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`),
navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`),
navArtist: (artistId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`);
},
navAlbum: (albumId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`);
},
}), []);
const listWrapRef = useRef<HTMLDivElement | null>(null);
@@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
import { coverServerScopeForServerId } from '../../cover/serverScope';
export interface TopFavoriteArtist {
id: string;
name: string;
count: number;
coverArtId: string;
/** Present when favorites are merged across servers. */
serverId?: string;
/** Raw artist id for song filtering (without server prefix). */
artistId?: string;
}
interface TopFavoriteArtistsRowProps {
@@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps {
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId;
const artistEntityId = artist.artistId ?? artist.coverArtId;
return (
<div
@@ -94,8 +100,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
<div className="artist-card-avatar">
{coverId ? (
<ArtistCoverArtImage
artistId={artist.id}
artistId={artistEntityId}
coverArt={artist.coverArtId}
serverScope={coverServerScopeForServerId(artist.serverId)}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
-54
View File
@@ -1,54 +0,0 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Music } from 'lucide-react';
import { useCachedUrl } from '../CachedImage';
// Album art box — crossfades layers so old art stays visible while new loads.
// Uses 300px thumbnails (portrait fallback uses 500px separately).
//
// Why onLoad instead of new Image() preload:
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
// before we flip it to 1.
export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
// true = show raw fetchUrl immediately as fallback while blob resolves.
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
// Showing the URL directly avoids the multi-second blank wait.
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
const counter = useRef(0);
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!blobUrl) return;
const id = ++counter.current;
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
}, [blobUrl]);
const handleLoad = useCallback((id: number) => {
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
}, []);
if (layers.length === 0) {
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
}
return (
<>
{layers.map(l => (
<img
key={l.id}
src={l.src}
className="fs-art"
style={{ opacity: l.vis ? 1 : 0 }}
onLoad={() => handleLoad(l.id)}
alt=""
decoding="async"
/>
))}
</>
);
});
@@ -0,0 +1,35 @@
import { memo, useEffect, useState } from 'react';
function formatClock(): string {
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
/**
* Standalone wall-clock for the fullscreen player. Owns its own state so the
* static player never re-renders for the time. Ticks every 30s (enough to catch
* the minute rollover) and pauses while the window/tab is hidden.
*/
export const FsClock = memo(function FsClock() {
const [time, setTime] = useState(formatClock);
useEffect(() => {
let id: number | undefined;
const tick = () => setTime(formatClock());
const sync = () => {
if (document.hidden) {
if (id !== undefined) { clearInterval(id); id = undefined; }
} else {
tick();
if (id === undefined) id = window.setInterval(tick, 30_000);
}
};
sync();
document.addEventListener('visibilitychange', sync);
return () => {
if (id !== undefined) clearInterval(id);
document.removeEventListener('visibilitychange', sync);
};
}, []);
return <span className="fsp-clock">{time}</span>;
});
@@ -1,77 +0,0 @@
import React, { memo, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
interface Props {
open: boolean;
onClose: () => void;
accentColor: string | null;
triggerRef?: React.RefObject<HTMLElement | null>;
}
// Lyrics settings popover — shown above the mic button.
export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
const { t } = useTranslation();
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
const panelRef = useRef<HTMLDivElement>(null);
// Close on click outside the panel or on Escape.
// Ignore clicks on the trigger button so re-clicking it toggles normally
// instead of outside-handler closing + click re-opening.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const onMouse = (e: MouseEvent) => {
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (triggerRef?.current?.contains(target)) return;
onClose();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
return () => {
clearTimeout(t);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onMouse);
};
}, [open, onClose, triggerRef]);
if (!open) return null;
const accent = accentColor ?? 'var(--accent)';
return (
<div className="fslm-panel" ref={panelRef}>
<div className="fslm-row">
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
<input
type="checkbox"
checked={showLyrics}
onChange={e => setLyrics(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
{(['rail', 'apple'] as const).map(style => (
<button
key={style}
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
onClick={() => setStyle(style)}
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
>
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
</button>
))}
</div>
<div className="fslm-arrow" />
</div>
);
});

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