Compare commits

32 Commits

Author SHA1 Message Date
Senko-san 89cf66f28a fix(api): refetch from server when online (stale-while-revalidate)
Docker Build & Publish / build (push) Successful in 35s
Docker Build & Publish / push (push) Failing after 2s
Docker Build & Publish / Prune old image versions (push) Has been skipped
The Tier-2 rehydrated cache seeded fulfilled entries at startup, so RTKQ served stale data and never hit the network (manual metadata edits, etc. only appeared after clearing the cache). Enable refetchOnMountOrArgChange + refetchOnReconnect + refetchOnFocus and wire setupListeners, so the cached snapshot still shows instantly but the server silently revalidates it whenever reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:13:59 +03:00
Senko-san f5a6b919aa fix(library): poll track list while enrichment is pending
The library tracks query wasn't refetching, so a track stayed on "Identifying metadata…" until an unrelated Track-tag invalidation. Poll every 4s while any listed track is metadata_status=pending, then stop (and never while offline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:09:47 +03:00
Senko-san 231887c3b7 feat(discover): wire A4 search + A5 downloads to backend
Adds DownloadJob/ExternalSearchResult/SourceInfo contract types + mappers, the downloads + search RTKQ endpoints, and the SearchDownloadPage (search external sources, per-result download states) and DownloadsManagerPage (active/history, progress, retry/cancel, poll-while-active). en/ru i18n. Snapshot also bundles in-progress queue/metadata-editor/storage UI work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:04:43 +03:00
Senko-san cdcacc56d1 fix(queue): marquee long track names + dedupe now-playing bars
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Queue sidebar no longer scrolls horizontally on long titles/artists:
text now ping-pong scrolls (news-ticker style) only when it overflows,
via a new Marquee component; .qd-scroll also clips overflow-x.

The current track previously showed the playing-bars indicator both in
place of the drag grip and over the cover. Keep only the cover overlay
and restore the drag grip on the current row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:28:27 +03:00
Senko-san b966ad8be5 fix(library): show album cover art in the Albums grid
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The album cards always fell back to the 💿 placeholder: the mapper dropped
the backend's `has_cover` and no album cover URL was ever built. Carry
`hasCover` through `RawAlbum`/`Album` and add `getAlbumCoverUrl` (GET
/albums/{id}/cover, token in the query like the track/stream URLs). The
Library Albums grid and the artist-detail discography now render real
covers, same source the album-detail page already used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:15:50 +03:00
Senko-san 6595417246 feat(storage): show device-local storage alongside the server
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The Storage dashboard only showed the remote server library. Split it into
two sections so both storages live there:

- "On this device": the Tier-3 service-worker audio cache (downloaded
  audio — usage gauge vs max, cached-track count) plus the offline library
  metadata (tracks/albums/artists browsable without the server, from the
  selectLocal* selectors). Always rendered, even with no backend.
- "On the server": the existing remote dashboard, now offline-aware — a
  quiet "server unreachable" notice instead of a blocking error when off.

- hook: useAudioCacheStats (reads getAudioCacheStats from the SW)
- i18n: storage.{device,server,audioCache,...} (en + ru)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:05:22 +03:00
Senko-san 94361899a8 feat(library): offline fallback for album & artist detail pages
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Extend the offline-library behaviour to the detail screens: with the
backend unreachable, both pages resolve their entity + tracks/albums from
the locally-cached library (reusing the `selectLocal*` selectors, filtered
by id) instead of showing a retry-only error.

- album detail: album + tracks from cache; offline banner; "not available
  offline" state when the album isn't cached; inner track states no longer
  error over locally-available tracks
- artist detail: artist + discography + tracks from cache; same treatment
- i18n: `common.offlineBanner`, `album.offline.*`, `artist.offline.*`
  (en + ru)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:49:39 +03:00
Senko-san 8a0e6782ad feat(library): render from locally-cached data when offline
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The Library showed a blocking error with the backend unreachable. Now it
composes a read-only library from everything already in the RTK Query
cache (Tier-2 rehydrated last-seen data + anything fetched this session),
so it keeps rendering offline instead of erroring.

- selectors: `selectLocalTracks/Albums/Artists` — memoized, union + dedupe
  across getTracks/getAlbums/getArtists, the per-album/artist list
  endpoints, and single-entity fetches; skips pending/rejected entries
- LibraryPage: when offline, fall back to the composed lists (live data
  still wins online), filter client-side for search, show an offline
  banner, and never show the retry-only ErrorState
- i18n: `library.offline.*` (en + ru)
- test: selector composition / dedup / status filtering (3 cases)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:44:45 +03:00
Senko-san 4aa071eeeb feat(upload): persistent "Recently uploaded" list (§A8)
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The transient client-side upload queue vanished on refresh, so a just-
uploaded track seemed to disappear. Add a server-backed "Recently
uploaded" section (source=upload, newest first) that survives refresh and
auto-refreshes after each upload (the upload mutation already invalidates
the `Track` tag this query provides).

- api: `source` filter on `LibraryFilters` → `GET /tracks?source=`
- i18n: `upload.recent.*` (en + ru); loading/error/empty states

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:36:07 +03:00
Senko-san 45a624b642 feat(artist): functional Artist detail screen (§A3)
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Replace the scaffold Placeholder with a real artist page wired to the
existing `/artists/{id}` (+ `/albums`, `/tracks`) endpoints: header with
procedural avatar / counts / "Play all" (queues with source=artist),
discography grid (cards → album detail), and the full track list. All
three list states per async section.

Also make the Library artists-tab rows clickable → `/artists/:id`,
closing the previous navigation dead-end.

i18n: new `artist.*` block (en + ru).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:30:06 +03:00
Senko-san 808c52484c feat(storage): functional Storage dashboard (§A6)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Replace the "coming soon" stub with a real dashboard wired to
`GET /storage`. modern-sk visuals: a layered disk-capacity gauge (library
share vs other-used vs free), stat tiles (tracks/artists/albums/playtime/
footprint/avg size), per-format size bars, metadata-health badges, source
breakdown, a popularity-weighted top-genres cloud, and playful fun facts.

- types: full `StorageStats` shape + `toStorageStats` snake→camel mapper
- endpoint: re-point `getStorageStats` to `GET /storage` with transform
- lib: `formatLongDuration` for big playtime spans
- i18n: `storage.*` keys (en + ru)
- three list states (loading / error / empty) per the UI invariant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:20:01 +03:00
Senko-san 44c8d1870f feat(queue): move shuffle/loop controls into queue drawer, scoped to queue
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
2026-06-13 18:17:21 +03:00
Senko-san a8e060d1a8 fix(player): show actual track format instead of hardcoded FLAC/320kbps
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
2026-06-13 18:06:35 +03:00
Senko-san 8ae447e08d feat(track): icon-based status badges, detect locally-cached tracks
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Replace the labelled availability/metadata badges in track rows with
small icon+tooltip indicators (cloud/hard-drives/warning/etc, derived
from TrackAvailability and MetadataStatus).

Add a `connection` slice fed by a single status poller (Sidebar) so
other components can cheaply check backend reachability. TrackRow uses
this plus the offline audio cache to show "Local" instead of a stale
"On server" when the backend is down but the track is already cached.
2026-06-13 18:00:48 +03:00
Senko-san df8c67b368 feat(album): single cover on album detail, track-number rows
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Album detail header falls back to a track's cover when the album
record itself has none, and each track row hides its per-track art in
favour of a large album-position number, since the header already
shows the album's cover once.
2026-06-13 17:51:55 +03:00
Senko-san f5767ff55e feat(track): show now-playing bars overlay on cover art
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Overlay the accent-coloured "hopping bars" PlayingIndicator on a
track's cover art wherever TrackRow appears when it's the active
player track, and reuse the same component/overlay for the current
entry's cover in the queue panel.
2026-06-13 17:49:06 +03:00
Senko-san 3984c7a499 feat(track): play-on-hover cover art, replacing double-click
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Add a play overlay button shown on cover art hover that inserts the
track into the queue right after the current track and jumps to it,
so it takes priority over what's already queued. Replaces the
double-click-to-play interaction with a new playNow queue action.
2026-06-13 17:44:35 +03:00
Senko-san b37fabd936 feat(queue): make queue tracks draggable via dnd-kit
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Wire up @dnd-kit sortable context in QueuePanel so tracks can be
reordered by dragging the grip handle, dispatching moveInQueue on drop.
2026-06-13 17:40:58 +03:00
Senko-san 9c70b8a11f feat(queue): add per-track overflow menu in queue panel
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Replace the bare "remove" cross on each queue row with a ghost
three-dot menu offering Play now, Move next (reposition right after
the current track), Track info, and Remove — consolidating the
previously separate info button into the same menu.
2026-06-13 17:37:17 +03:00
Senko-san 5c8f89675d feat(queue): unified persistent queue list with playing indicator
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Show all queue entries (played and upcoming) in one list instead of
splitting into a "Now playing" card + "Next up" tail, so previously
played tracks don't disappear and reappear when navigating back/forward.
The current track is outlined and shows a reusable "hopping bars"
PlayingIndicator (modern-sk style equalizer animation) for future reuse
across track lists.
2026-06-13 17:18:38 +03:00
Senko-san df2531171e fix(queue): resolve real track covers in queue panel
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Queue rows passed albumArtUrl straight to ArtTile, but for tracks that
field is usually empty — the real cover is served per-track from
/tracks/{id}/cover. Apply the same resolution PersistentPlayer uses
(getCoverUrl ?? getTrackCoverUrl) for both the now-playing tile and the
up-next rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 17:06:21 +03:00
Senko-san d1b2b40ffd feat(metadata): implement single-track metadata editor page (§A7)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Replace the placeholder with a controlled form for title/artist/album/
year/genre/track number, an AcoustID "find matches" action showing
ranked candidates with confidence, a diff/apply picker, a re-enrich
button, and save via PUT /metadata. Adds matches/apply API endpoints,
mappers, types, and en/ru i18n strings. Batch editor remains a
placeholder (deferred).
2026-06-13 14:36:17 +03:00
Senko-san 8a70f478c3 feat: track info drawer (Get Info-style)
Add a right-side track info drawer that sits to the right of the queue
panel when both are open. Shows a large cover, title/artist/album links,
a Play/Queue/Edit actions row, and Status/General/File/Identifiers
sections (empty rows omitted). Opens from the track context menu, the
player now-playing tile, and the queue now-playing card.

- ui slice: trackInfoId + open/closeTrackInfo
- TrackInfoDrawer rendered after QueuePanel in AppShell; overlays content
  on narrow viewports
- map source/createdAt/enrichedAt from the wire (were unmapped)
- formatDateTime helper, info icon, i18n (en/ru)
- drop orphaned toggleNowPlaying/isNowPlayingOpen from player slice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:02:38 +03:00
Senko-san 9c344b98c4 fix(player): show live track metadata, not the stale queue snapshot
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The queue slice stores denormalized display fields captured at play-time, so the
player and queue panel kept showing pre-enrichment title/artist after a track's
metadata was updated — the library (RTKQ cache) and the player disagreed.

Add useResolvedQueueEntry: read through to the RTKQ Track cache and prefer its
fresh values, keeping the snapshot only as instant/offline fallback. Wire it into
PersistentPlayer (now-playing + cover) and QueuePanel (now-playing + up-next
rows), so enrichment updates reach the player through the same Track tags that
refresh the library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:37:34 +03:00
Senko-san 42080b37ea chore: login page upd
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
2026-06-13 13:30:17 +03:00
Senko-san a37c19fd45 feat(library): surface metadata enrichment status, errors and covers
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The mapper dropped metadata_status and hardcoded availability, so enrichment
state was invisible and a just-uploaded track never appeared to change. Map
metadata_status/metadata_error/has_cover onto Track; add MetadataStatusBadge
(pending spinner / enriched / failed-with-reason / manual) shown in TrackRow,
and serve token-bearing track covers via getTrackCoverUrl.

UploadPage now polls each uploaded track (stops once enrichment settles) so the
resolved title/artist — or a failure reason — appears live. i18n in en + ru.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:29:22 +03:00
Senko-san facc215450 chore: update/make more clear connect flow 2026-06-13 12:35:20 +03:00
olly 98e9344261 chore: bump modern-sk ver.
Docker Build & Publish / build (push) Successful in 34s
Docker Build & Publish / push (push) Failing after 3m3s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-10 20:09:27 +03:00
Senko-san 1228118027 fix(offline): include provided in RTKQ rehydration payload
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
RTK Query 2.12's invalidation slice reads `provided.tags` during cache
rehydration (`Object.entries(provided.tags ?? {})`). Our persisted
snapshot only carried `{ queries, mutations }`, so `provided` was
undefined and `.tags` threw on every startup with a cached snapshot —
crashing the app inside the rehydrate reducer / immer produce.

Snapshot now carries the real `provided` (so invalidation tags
rehydrate), and `load()` defaults it to `{ tags: {}, keys: {} }` so
snapshots persisted before this field existed recover without a manual
localStorage clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:29:42 +03:00
Senko-san 538cfb9c5b feat(auth): registration mode on ConnectPage (PUBLIC_ENABLE_REGISTRATION)
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
Add a login/register toggle to ConnectPage backed by a new
useRegisterMutation (register -> /auth/me, mirroring login). The toggle
is shown only when REGISTRATION_ENABLED, resolved with the same
precedence as the API base URL: runtime window.__APP_CONFIG__ >
PUBLIC_ENABLE_REGISTRATION env > default true. The prod runtime-config
script injects the runtime flag. The backend's ALLOW_REGISTRATION stays
the real authority; this only gates the UI. EN/RU strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:07:07 +03:00
olly 2ad3b128d6 fix: backend url normalization
Docker Build & Publish / build (push) Failing after 3m9s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-10 13:49:38 +03:00
Senko-san 55aa8933af fix(theme): kill flash of white on dark-themed load
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
The app painted white until <ThemeProvider> mounted and set data-theme,
then snapped to the dark theme. Two fixes:

- Inline head script (rsbuild html.tags) sets data-theme before first
  paint, mirroring modern-sk's exact logic (localStorage 'modern-sk-theme'
  || 'dark') so there's no second flip when the provider mounts. Inline =
  zero round-trips.
- body now paints var(--color-bg) so the themed background shows before
  React mounts #root and layers the felt grain on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:18:29 +03:00
64 changed files with 5818 additions and 584 deletions
+4
View File
@@ -1,2 +1,6 @@
# Default backend URL (overridable at runtime in the UI) # Default backend URL (overridable at runtime in the UI)
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1 PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
# Show the public sign-up UI on the connect screen. Set to false to hide it.
# The backend's ALLOW_REGISTRATION is the real authority; this only gates the UI.
PUBLIC_ENABLE_REGISTRATION=true
+3 -3
View File
@@ -7,15 +7,15 @@ on:
env: env:
# Number of tagged (non-latest) versions to keep per image name. # Number of tagged (non-latest) versions to keep per image name.
KEEP_VERSIONS: "5" KEEP_VERSIONS: '5'
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
host: ${{ steps.meta.outputs.host }} host: ${{ steps.meta.outputs.host }}
image: ${{ steps.meta.outputs.image }} image: ${{ steps.meta.outputs.image }}
sha: ${{ steps.meta.outputs.sha }} sha: ${{ steps.meta.outputs.sha }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+15 -5
View File
@@ -2,15 +2,25 @@
# Write the SPA's runtime operator config at container start. # Write the SPA's runtime operator config at container start.
# #
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching # The nginx base image runs every /docker-entrypoint.d/*.sh before launching
# nginx, so this overwrites the build-time public/config.js stub with the value # nginx, so this overwrites the build-time public/config.js stub with the
# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend # operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION).
# origin without rebuilding. Resolution + precedence live in src/config/env.ts. # That lets one prebuilt image target any backend origin and toggle sign-up
# without rebuilding. Resolution + precedence live in src/config/env.ts.
set -eu set -eu
: "${PUBLIC_API_BASE_URL:=/api/v1}" : "${PUBLIC_API_BASE_URL:=/api/v1}"
: "${PUBLIC_ENABLE_REGISTRATION:=true}"
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}" ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \ # Anything but "false"/"0" enables the sign-up UI (mirrors parseFlag in env.ts).
if [ "$PUBLIC_ENABLE_REGISTRATION" = "false" ] || [ "$PUBLIC_ENABLE_REGISTRATION" = "0" ]; then
ENABLE_REGISTRATION=false
else
ENABLE_REGISTRATION=true
fi
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s","enableRegistration":%s};\n' \
"$PUBLIC_API_BASE_URL" "$ENABLE_REGISTRATION" \
>"$ROOT/config.js" >"$ROOT/config.js"
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js" echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js"
+74 -5
View File
@@ -8,7 +8,10 @@
"name": "mcma-webui", "name": "mcma-webui",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@olly/modern-sk": "0.1.4-3", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"i18next": "^26.3.1", "i18next": "^26.3.1",
@@ -16,7 +19,8 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router": "^7.16.0" "react-router": "^7.16.0",
"use-debounce": "^10.1.1"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^2.0.7", "@rsbuild/core": "^2.0.7",
@@ -551,6 +555,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -693,9 +750,9 @@
} }
}, },
"node_modules/@olly/modern-sk": { "node_modules/@olly/modern-sk": {
"version": "0.1.4-3", "version": "0.1.5",
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.4-3/modern-sk-0.1.4-3.tgz", "resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.5/modern-sk-0.1.5.tgz",
"integrity": "sha512-h+d+Jd3DBr7d51V78p1Eb5rVrpN4PAskwQFnh2X4Dk7Q8oajiMVJuhZU1amx97bKHFDHgcOfhwc4cS8P4tFCmQ==", "integrity": "sha512-rhKp4U2IovSZkgdfg4oZqyhF0GgB8oR5TPlPXg0iYQEuEtff5zAgRXS+uY3dOPg2tStG3ysHUJaohD9YS2ADiA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
@@ -4469,6 +4526,18 @@
} }
} }
}, },
"node_modules/use-debounce": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
"integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-sidecar": { "node_modules/use-sidecar": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+6 -2
View File
@@ -13,7 +13,10 @@
"test:watch": "rstest --watch" "test:watch": "rstest --watch"
}, },
"dependencies": { "dependencies": {
"@olly/modern-sk": "0.1.4-3", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"i18next": "^26.3.1", "i18next": "^26.3.1",
@@ -21,7 +24,8 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router": "^7.16.0" "react-router": "^7.16.0",
"use-debounce": "^10.1.1"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^2.0.7", "@rsbuild/core": "^2.0.7",
+5 -2
View File
@@ -60,7 +60,9 @@ async function handleAudio(event) {
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a // 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
// complete copy, then satisfy the original request (range-sliced if asked). // complete copy, then satisfy the original request (range-sliced if asked).
try { try {
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) }); const fullReq = new Request(req.url, {
headers: withoutRange(req.headers),
});
const resp = await fetch(fullReq); const resp = await fetch(fullReq);
if (isCacheable(resp)) { if (isCacheable(resp)) {
event.waitUntil(storeInCache(key, resp.clone())); event.waitUntil(storeInCache(key, resp.clone()));
@@ -135,7 +137,8 @@ async function buildRangeResponse(response, rangeHeader) {
const buf = await response.clone().arrayBuffer(); const buf = await response.clone().arrayBuffer();
const size = buf.byteLength; const size = buf.byteLength;
const r = parseRangeHeader(rangeHeader, size); const r = parseRangeHeader(rangeHeader, size);
const type = response.headers.get('content-type') || 'application/octet-stream'; const type =
response.headers.get('content-type') || 'application/octet-stream';
if (!r) { if (!r) {
return new Response(buf, { return new Response(buf, {
+12
View File
@@ -36,6 +36,18 @@ export default defineConfig({
// "Install app". The service worker (audio offline cache) is registered // "Install app". The service worker (audio offline cache) is registered
// from src/index.tsx, not here. // from src/index.tsx, not here.
tags: [ tags: [
// Theme bootstrap — runs inline before first paint to kill the flash of
// white on a dark-themed load. Mirrors modern-sk's own logic exactly
// (localStorage 'modern-sk-theme' || 'dark' → data-theme on <html>), so
// there's no second flip when <ThemeProvider> mounts. Inline (not an
// external file) so it costs zero round-trips.
{
tag: 'script',
children:
"(function(){try{var t=localStorage.getItem('modern-sk-theme')||'dark';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();",
head: true,
append: false,
},
// Runtime operator config. A classic (non-deferred) head script, so it // Runtime operator config. A classic (non-deferred) head script, so it
// runs before the deferred app bundle and window.__APP_CONFIG__ is set by // runs before the deferred app bundle and window.__APP_CONFIG__ is set by
// the time src/config/env.ts reads it. Served from public/ in dev and // the time src/config/env.ts reads it. Served from public/ in dev and
+31 -1
View File
@@ -1,6 +1,12 @@
import { api } from '../index'; import { api } from '../index';
import { toUser, type RawUser } from '../mappers'; import { toUser, type RawUser } from '../mappers';
import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types'; import type {
AuthTokens,
LoginRequest,
LoginResponse,
RegisterRequest,
User,
} from '../types';
/** /**
* Auth seam over the backend's wire format: tokens-only login + a separate * Auth seam over the backend's wire format: tokens-only login + a separate
@@ -48,6 +54,29 @@ export const authApi = api.injectEndpoints({
return { data: { user, tokens } }; return { data: { user, tokens } };
}, },
}), }),
// Sign-up mirrors login: POST /auth/register returns a token pair (the
// backend logs the new account straight in), then GET /auth/me resolves the
// user — so the UI gets the same unified { user, tokens } as login.
register: build.mutation<LoginResponse, RegisterRequest>({
async queryFn(body, _api, _extra, baseQuery) {
const tokenRes = await baseQuery({
url: '/auth/register',
method: 'POST',
body,
});
if (tokenRes.error) return { error: tokenRes.error };
const tokens = toTokens(tokenRes.data as RawTokenResponse);
const meRes = await baseQuery({
url: '/auth/me',
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (meRes.error) return { error: meRes.error };
const user = toUser(meRes.data as RawUser);
return { data: { user, tokens } };
},
}),
logout: build.mutation<void, { refreshToken: string }>({ logout: build.mutation<void, { refreshToken: string }>({
query: ({ refreshToken }) => ({ query: ({ refreshToken }) => ({
url: '/auth/logout', url: '/auth/logout',
@@ -74,6 +103,7 @@ export const authApi = api.injectEndpoints({
export const { export const {
useLoginMutation, useLoginMutation,
useRegisterMutation,
useLogoutMutation, useLogoutMutation,
useRefreshTokenMutation, useRefreshTokenMutation,
useMeQuery, useMeQuery,
+80 -20
View File
@@ -1,30 +1,88 @@
import { api } from '../index'; import { api } from '../index';
import type { DownloadJob } from '../types'; import {
toDownloadJob,
toPage,
type RawDownloadJob,
type RawPaged,
} from '../mappers';
import type {
DownloadJob,
DownloadRequestResult,
PaginatedResponse,
} from '../types';
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they interface ListParams {
// return no body / no schema). The request shapes below are provisional and the status?: DownloadJob['status'];
// responses will need the same snake→camel mapper treatment as library/playlists /** Only the current user's jobs (backend `mine=true`). */
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not mine?: boolean;
// wire these into the UI until then. page?: number;
pageSize?: number;
}
interface RawCreateResponse {
already_in_library: boolean;
track_id: string | null;
job: RawDownloadJob | null;
}
export const downloadsApi = api.injectEndpoints({ export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getDownloads: build.query< getDownloads: build.query<
DownloadJob[], PaginatedResponse<DownloadJob>,
{ status?: DownloadJob['status'] } | void ListParams | void
>({ >({
query: (params) => ({ url: '/downloads', params: params ?? {} }), query: (params) => {
providesTags: ['Download'], const p = params ?? {};
const size = p.pageSize ?? 50;
return {
url: '/downloads',
params: {
status: p.status,
mine: p.mine ? true : undefined,
limit: size,
offset: ((p.page ?? 1) - 1) * size,
},
};
},
transformResponse: (raw: RawPaged<RawDownloadJob>) =>
toPage(raw, toDownloadJob),
providesTags: (result) =>
result
? [
...result.items.map(({ id }) => ({
type: 'Download' as const,
id,
})),
'Download',
]
: ['Download'],
}), }),
addDownload: build.mutation< getDownload: build.query<DownloadJob, string>({
DownloadJob, query: (id) => `/downloads/${id}`,
{ transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
url: string; providesTags: (_r, _e, id) => [{ type: 'Download', id }],
metadata?: { title?: string; artist?: string; album?: string }; }),
} /** Request a download of a discovered item (§A4 "Download to library"). */
createDownload: build.mutation<
DownloadRequestResult,
{ source: string; sourceId: string; query?: string }
>({ >({
query: (body) => ({ url: '/downloads', method: 'POST', body }), query: (body) => ({
invalidatesTags: ['Download'], url: '/downloads',
method: 'POST',
body: {
source: body.source,
source_id: body.sourceId,
query: body.query,
},
}),
transformResponse: (raw: RawCreateResponse): DownloadRequestResult => ({
alreadyInLibrary: raw.already_in_library,
trackId: raw.track_id ?? undefined,
job: raw.job ? toDownloadJob(raw.job) : undefined,
}),
// A completed dedup can surface an existing library track; refresh both.
invalidatesTags: ['Download', 'Track'],
}), }),
cancelDownload: build.mutation<void, string>({ cancelDownload: build.mutation<void, string>({
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }), query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
@@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({
}), }),
retryDownload: build.mutation<DownloadJob, string>({ retryDownload: build.mutation<DownloadJob, string>({
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }), query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
invalidatesTags: ['Download'], transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
invalidatesTags: (_r, _e, id) => [{ type: 'Download', id }, 'Download'],
}), }),
}), }),
overrideExisting: false, overrideExisting: false,
@@ -40,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
export const { export const {
useGetDownloadsQuery, useGetDownloadsQuery,
useAddDownloadMutation, useGetDownloadQuery,
useCreateDownloadMutation,
useCancelDownloadMutation, useCancelDownloadMutation,
useRetryDownloadMutation, useRetryDownloadMutation,
} = downloadsApi; } = downloadsApi;
+43
View File
@@ -2,10 +2,12 @@ import { api } from '../index';
import { import {
toAlbum, toAlbum,
toArtist, toArtist,
toMetadataMatch,
toPage, toPage,
toTrack, toTrack,
type RawAlbum, type RawAlbum,
type RawArtist, type RawArtist,
type RawMetadataMatch,
type RawPaged, type RawPaged,
type RawTrack, type RawTrack,
} from '../mappers'; } from '../mappers';
@@ -13,6 +15,8 @@ import type {
Track, Track,
Album, Album,
Artist, Artist,
MetadataEdit,
MetadataMatch,
PaginatedResponse, PaginatedResponse,
LibraryFilters, LibraryFilters,
} from '../types'; } from '../types';
@@ -38,6 +42,7 @@ function trackParams(f: LibraryFilters) {
q: f.search, q: f.search,
artist_id: f.artistId, artist_id: f.artistId,
album_id: f.albumId, album_id: f.albumId,
source: f.source,
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined, sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
order: f.sortOrder, order: f.sortOrder,
...paging(f.page, f.pageSize), ...paging(f.page, f.pageSize),
@@ -161,6 +166,41 @@ export const libraryApi = api.injectEndpoints({
}), }),
providesTags: ['Track', 'Album', 'Artist'], providesTags: ['Track', 'Album', 'Artist'],
}), }),
getMetadataMatches: build.query<MetadataMatch[], string>({
query: (trackId) => `/tracks/${trackId}/metadata/matches`,
transformResponse: (raw: { items: RawMetadataMatch[] }) =>
raw.items.map(toMetadataMatch),
}),
applyMetadata: build.mutation<
Track,
{ trackId: string; edit: MetadataEdit }
>({
query: ({ trackId, edit }) => ({
url: `/tracks/${trackId}/metadata`,
method: 'PUT',
body: {
title: edit.title,
artist_name: edit.artistName,
album_title: edit.albumTitle,
year: edit.year,
genre: edit.genre,
track_number: edit.trackNumber,
},
}),
transformResponse: (raw: RawTrack) => toTrack(raw),
invalidatesTags: (_r, _e, { trackId }) => [
{ type: 'Track', id: trackId },
'Album',
'Artist',
],
}),
enrichTrack: build.mutation<{ track_id: string; job_id: string }, string>({
query: (trackId) => ({
url: `/tracks/${trackId}/metadata/enrich`,
method: 'POST',
}),
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
}),
}), }),
overrideExisting: false, overrideExisting: false,
}); });
@@ -176,4 +216,7 @@ export const {
useGetArtistAlbumsQuery, useGetArtistAlbumsQuery,
useGetArtistTracksQuery, useGetArtistTracksQuery,
useSearchLibraryQuery, useSearchLibraryQuery,
useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation,
useEnrichTrackMutation,
} = libraryApi; } = libraryApi;
+48
View File
@@ -0,0 +1,48 @@
import { api } from '../index';
import {
toSearchResult,
toSourceInfo,
type RawSearchResult,
type RawSourceInfo,
} from '../mappers';
import type { ExternalSearchResult, SourceInfo } from '../types';
interface RawSearchResponse {
results: RawSearchResult[];
searched_sources: string[];
}
interface SearchResponse {
results: ExternalSearchResult[];
/** Names of the sources actually queried (available ones). */
searchedSources: string[];
}
const mapSearch = (raw: RawSearchResponse): SearchResponse => ({
results: raw.results.map(toSearchResult),
searchedSources: raw.searched_sources,
});
export const searchApi = api.injectEndpoints({
endpoints: (build) => ({
/** Registered source backends (for the §A4 source picker). */
getSources: build.query<SourceInfo[], void>({
query: () => '/sources',
transformResponse: (raw: RawSourceInfo[]) => raw.map(toSourceInfo),
}),
/** Search across every available fetch source (no `source` → aggregate). */
searchExternal: build.query<
SearchResponse,
{ q: string; source?: string; limit?: number }
>({
query: ({ q, source, limit }) =>
source
? { url: `/sources/${source}/search`, params: { q, limit } }
: { url: '/search', params: { q, limit } },
transformResponse: mapSearch,
}),
}),
overrideExisting: false,
});
export const { useGetSourcesQuery, useLazySearchExternalQuery } = searchApi;
+6 -7
View File
@@ -1,17 +1,16 @@
import { api } from '../index'; import { api } from '../index';
import { toStorageStats, type RawStorageStats } from '../mappers';
import type { StorageStats } from '../types'; import type { StorageStats } from '../types';
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body / // `GET /storage` returns library + disk statistics (§A6). The maintenance
// no schema), and the real paths differ from these placeholders (`GET /storage`, // routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`, // `POST /storage/cleanup`) are still backend stubs and unused by the UI.
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
// `mappers.ts`) once the backend defines the storage response shapes; until then
// these are provisional and unused by the UI.
export const storageApi = api.injectEndpoints({ export const storageApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({ getStorageStats: build.query<StorageStats, void>({
query: () => '/storage/stats', query: () => '/storage',
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
providesTags: ['Storage'], providesTags: ['Storage'],
}), }),
scanStorage: build.mutation<{ jobId: string }, void>({ scanStorage: build.mutation<{ jobId: string }, void>({
+30
View File
@@ -17,3 +17,33 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
const base = getApiBaseUrl(); const base = getApiBaseUrl();
return `${base}${artUrl}`; return `${base}${artUrl}`;
} }
/**
* Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the
* audio stream, an `<img>` can't send an `Authorization` header, so the access
* token rides as `?token=`. Returns undefined when the track has no cover.
*/
export function getTrackCoverUrl(
trackId: string,
token: string,
hasCover: boolean,
): string | undefined {
if (!hasCover) return undefined;
const base = getApiBaseUrl();
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
}
/**
* Cover image URL for an album, served by `GET /albums/{id}/cover`. Same
* `?token=` rationale as the track cover. Returns undefined when the album has
* no cover (so callers fall back to generated tile art).
*/
export function getAlbumCoverUrl(
albumId: string,
token: string,
hasCover: boolean,
): string | undefined {
if (!hasCover) return undefined;
const base = getApiBaseUrl();
return `${base}/albums/${albumId}/cover?token=${encodeURIComponent(token)}`;
}
+9
View File
@@ -5,6 +5,15 @@ import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
export const api = createApi({ export const api = createApi({
reducerPath: 'api', reducerPath: 'api',
baseQuery: baseQueryWithReauth, baseQuery: baseQueryWithReauth,
// Stale-while-revalidate. The Tier-2 rehydrated cache (below) seeds fulfilled
// entries at startup, which would otherwise make RTKQ serve stale data and
// never hit the network. These flags keep showing the cached snapshot
// instantly but silently refetch from the server whenever it's reachable —
// on mount/arg change, on reconnect, and on window refocus. The result: the
// server is the source of truth when online; the cache is only a fallback.
refetchOnMountOrArgChange: true,
refetchOnReconnect: true,
refetchOnFocus: true,
tagTypes: [ tagTypes: [
'Track', 'Track',
'Album', 'Album',
+187 -2
View File
@@ -14,12 +14,33 @@
import type { import type {
Album, Album,
Artist, Artist,
DownloadJob,
DownloadStatus,
ExternalSearchResult,
MetadataMatch,
MetadataStatus,
PaginatedResponse, PaginatedResponse,
Playlist, Playlist,
SourceInfo,
StorageStats,
Track, Track,
User, User,
} from './types'; } from './types';
const METADATA_STATUSES: readonly MetadataStatus[] = [
'pending',
'enriched',
'failed',
'manual',
];
/** Map the backend's free-form status string onto the UI union, defaulting any
* unknown value to `pending` (a safe "not yet identified" state). */
const toMetadataStatus = (raw: string): MetadataStatus =>
(METADATA_STATUSES as readonly string[]).includes(raw)
? (raw as MetadataStatus)
: 'pending';
// ---- raw wire shapes (snake_case, exactly as the backend emits) ---- // ---- raw wire shapes (snake_case, exactly as the backend emits) ----
export interface RawPaged<T> { export interface RawPaged<T> {
@@ -48,11 +69,29 @@ export interface RawTrack {
duration_seconds: number | null; duration_seconds: number | null;
file_format: string; file_format: string;
file_size: number; file_size: number;
genre: string | null;
year: number | null;
track_number: number | null;
metadata_status: string; metadata_status: string;
metadata_error: string | null;
enriched_at: string | null;
has_cover: boolean;
source: string; source: string;
created_at: string; created_at: string;
} }
/** One AcoustID candidate, as returned by `GET /tracks/{id}/metadata/matches`. */
export interface RawMetadataMatch {
acoustid: string;
score: number;
recording_mbid: string | null;
release_group_mbid: string | null;
title: string | null;
artist: string | null;
album: string | null;
year: number | null;
}
export interface RawAlbum { export interface RawAlbum {
id: string; id: string;
title: string; title: string;
@@ -60,6 +99,7 @@ export interface RawAlbum {
artist_name: string; artist_name: string;
year: number | null; year: number | null;
track_count: number; track_count: number;
has_cover: boolean;
created_at: string; created_at: string;
} }
@@ -98,16 +138,37 @@ export const toTrack = (r: RawTrack): Track => ({
artistName: r.artist_name, artistName: r.artist_name,
albumId: r.album_id ?? '', albumId: r.album_id ?? '',
albumTitle: r.album_title ?? '', albumTitle: r.album_title ?? '',
// Cover endpoints aren't wired on the backend yet — leave art undefined so the // `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
// UI renders generated tile art instead of a broken image. // built in the component from the track id — see `getTrackCoverUrl`. Keep
// `albumArtUrl` undefined so callers fall back to generated tile art.
albumArtUrl: undefined, albumArtUrl: undefined,
hasCover: r.has_cover,
durationMs: (r.duration_seconds ?? 0) * 1000, durationMs: (r.duration_seconds ?? 0) * 1000,
// The lean TrackOut carries no availability/like state: a track returned by // The lean TrackOut carries no availability/like state: a track returned by
// the library is on the server, and per-track like state comes from /likes. // the library is on the server, and per-track like state comes from /likes.
availability: 'server', availability: 'server',
metadataStatus: toMetadataStatus(r.metadata_status),
metadataError: r.metadata_error ?? undefined,
genre: r.genre ?? undefined,
year: r.year ?? undefined,
trackNumber: r.track_number ?? undefined,
liked: false, liked: false,
format: r.file_format, format: r.file_format,
fileSize: r.file_size, fileSize: r.file_size,
source: r.source,
createdAt: r.created_at,
enrichedAt: r.enriched_at ?? undefined,
});
export const toMetadataMatch = (r: RawMetadataMatch): MetadataMatch => ({
acoustid: r.acoustid,
score: r.score,
recordingMbid: r.recording_mbid ?? undefined,
releaseGroupMbid: r.release_group_mbid ?? undefined,
title: r.title ?? undefined,
artist: r.artist ?? undefined,
album: r.album ?? undefined,
year: r.year ?? undefined,
}); });
export const toAlbum = (r: RawAlbum): Album => ({ export const toAlbum = (r: RawAlbum): Album => ({
@@ -115,7 +176,10 @@ export const toAlbum = (r: RawAlbum): Album => ({
title: r.title, title: r.title,
artistId: r.artist_id, artistId: r.artist_id,
artistName: r.artist_name, artistName: r.artist_name,
// The album record carries no cover *URL*; `hasCover` says one exists, and the
// URL (which needs `?token=`) is built in components via `getAlbumCoverUrl`.
artUrl: undefined, artUrl: undefined,
hasCover: r.has_cover,
year: r.year ?? undefined, year: r.year ?? undefined,
trackCount: r.track_count, trackCount: r.track_count,
// AlbumOut has no aggregate duration; computed client-side from tracks when // AlbumOut has no aggregate duration; computed client-side from tracks when
@@ -145,6 +209,127 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
updatedAt: r.created_at, updatedAt: r.created_at,
}); });
interface RawStorageStats {
total_tracks: number;
total_artists: number;
total_albums: number;
total_size: number;
total_duration_seconds: number;
largest_track_size: number;
earliest_added: string | null;
latest_added: string | null;
by_format: { file_format: string; track_count: number; total_size: number }[];
by_metadata_status: Record<string, number>;
by_source: Record<string, number>;
top_genres: { genre: string; track_count: number }[];
disk: { total: number; used: number; free: number } | null;
}
export type { RawStorageStats };
export const toStorageStats = (r: RawStorageStats): StorageStats => ({
totalTracks: r.total_tracks,
totalArtists: r.total_artists,
totalAlbums: r.total_albums,
totalSize: r.total_size,
totalDurationSeconds: r.total_duration_seconds,
largestTrackSize: r.largest_track_size,
earliestAdded: r.earliest_added ?? undefined,
latestAdded: r.latest_added ?? undefined,
byFormat: r.by_format.map((f) => ({
fileFormat: f.file_format,
trackCount: f.track_count,
totalSize: f.total_size,
})),
byMetadataStatus: r.by_metadata_status,
bySource: r.by_source,
topGenres: r.top_genres.map((g) => ({
genre: g.genre,
trackCount: g.track_count,
})),
disk: r.disk ?? undefined,
});
// ---- downloads + external search ----
const DOWNLOAD_STATUSES: readonly DownloadStatus[] = [
'queued',
'downloading',
'enriching',
'done',
'failed',
];
const toDownloadStatus = (raw: string): DownloadStatus =>
(DOWNLOAD_STATUSES as readonly string[]).includes(raw)
? (raw as DownloadStatus)
: 'queued';
export interface RawDownloadJob {
id: string;
source: string;
source_id: string | null;
query: string | null;
status: string;
progress: number;
error_message: string | null;
retry_count: number;
track_id: string | null;
created_at: string;
updated_at: string;
}
export const toDownloadJob = (r: RawDownloadJob): DownloadJob => ({
id: r.id,
source: r.source,
sourceId: r.source_id ?? undefined,
query: r.query ?? undefined,
status: toDownloadStatus(r.status),
progress: r.progress,
errorMessage: r.error_message ?? undefined,
trackId: r.track_id ?? undefined,
retryCount: r.retry_count,
createdAt: r.created_at,
updatedAt: r.updated_at,
});
export interface RawSearchResult {
source: string;
source_id: string;
title: string;
artist: string | null;
album: string | null;
duration_seconds: number | null;
thumbnail_url: string | null;
}
export const toSearchResult = (r: RawSearchResult): ExternalSearchResult => ({
source: r.source,
sourceId: r.source_id,
title: r.title,
artist: r.artist ?? undefined,
album: r.album ?? undefined,
durationMs:
r.duration_seconds != null ? r.duration_seconds * 1000 : undefined,
thumbnailUrl: r.thumbnail_url ?? undefined,
});
export interface RawSourceInfo {
name: string;
label: string;
kind: string;
available: boolean;
}
export const toSourceInfo = (r: RawSourceInfo): SourceInfo => ({
name: r.name,
label: r.label,
// Backend kinds are `indexable` | `fetch`; default unknowns to indexable
// (the conservative, non-searchable bucket).
kind: r.kind === 'fetch' ? 'fetch' : 'indexable',
available: r.available,
});
/** /**
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's * Translate the backend's `{items,total,limit,offset}` envelope into the UI's
* `{items,total,page,pageSize,hasMore}`, mapping each element. * `{items,total,page,pageSize,hasMore}`, mapping each element.
+5
View File
@@ -15,6 +15,11 @@ export const REHYDRATE_API = 'api/rehydrate';
export interface RehydrateApiPayload { export interface RehydrateApiPayload {
queries: Record<string, unknown>; queries: Record<string, unknown>;
mutations: Record<string, unknown>; mutations: Record<string, unknown>;
// RTKQ's invalidation slice reads `provided.tags`/`provided.keys` during
// rehydration (it does `Object.entries(provided.tags ?? {})`), so `provided`
// must be an object — a bare `{ queries, mutations }` makes it crash on
// `provided.tags` of undefined. Always present; empty objects are valid.
provided: { tags: Record<string, unknown>; keys: Record<string, unknown> };
} }
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API); export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
+130 -10
View File
@@ -1,5 +1,13 @@
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing'; export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
/**
* Metadata-enrichment state, distinct from file `availability`. `pending` = the
* worker hasn't finished (or hasn't started); `enriched` = identity found;
* `failed` = no match / a worker error (see `metadataError`); `manual` = user-
* edited and never auto-overwritten.
*/
export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual';
export interface Track { export interface Track {
id: string; id: string;
title: string; title: string;
@@ -8,16 +16,26 @@ export interface Track {
albumId: string; albumId: string;
albumTitle: string; albumTitle: string;
albumArtUrl?: string; albumArtUrl?: string;
hasCover: boolean;
durationMs: number; durationMs: number;
trackNumber?: number; trackNumber?: number;
discNumber?: number; discNumber?: number;
year?: number; year?: number;
genre?: string; genre?: string;
availability: TrackAvailability; availability: TrackAvailability;
metadataStatus: MetadataStatus;
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
metadataError?: string;
fileSize?: number; fileSize?: number;
format?: string; format?: string;
bitrate?: number; bitrate?: number;
liked: boolean; liked: boolean;
/** Where the track entered the library (e.g. `upload`, `local_folder`). */
source?: string;
/** ISO timestamp the track was added to the library. */
createdAt?: string;
/** ISO timestamp the last successful enrichment ran; undefined if never. */
enrichedAt?: string;
} }
export interface Album { export interface Album {
@@ -26,6 +44,8 @@ export interface Album {
artistId: string; artistId: string;
artistName: string; artistName: string;
artUrl?: string; artUrl?: string;
/** Whether the album has cover art served by `GET /albums/{id}/cover`. */
hasCover: boolean;
year?: number; year?: number;
trackCount: number; trackCount: number;
totalDurationMs: number; totalDurationMs: number;
@@ -58,32 +78,102 @@ export interface PlaylistTrack extends Track {
addedAt: string; addedAt: string;
} }
/** Lifecycle of a download job, mirroring the backend's `DownloadStatus`.
* `enriching` = file fetched, metadata pipeline running; `done` = imported. */
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'enriching'
| 'done'
| 'failed';
export interface DownloadJob { export interface DownloadJob {
id: string; id: string;
url: string; /** Source backend the job pulls from (e.g. `youtube`). */
title?: string; source: string;
artist?: string; /** Stable per-source id of the item (e.g. a YouTube videoId). */
album?: string; sourceId?: string;
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error'; /** The free-text query the job was created from, for display. */
query?: string;
status: DownloadStatus;
/** Fraction complete, 0..1. */
progress: number; progress: number;
errorMessage?: string; errorMessage?: string;
/** Set once the download finishes and the library track exists. */
trackId?: string; trackId?: string;
retryCount: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
/** Result of POST /downloads. Either the item is already in the library
* (`alreadyInLibrary`, `trackId` set), or a job covers it (`job`). */
export interface DownloadRequestResult {
alreadyInLibrary: boolean;
trackId?: string;
job?: DownloadJob;
}
/** One hit from an external (fetch) source — the §A4 discover screen. */
export interface ExternalSearchResult {
source: string;
sourceId: string;
title: string;
artist?: string;
album?: string;
durationMs?: number;
thumbnailUrl?: string;
}
/** A registered source backend, from GET /sources. `kind`: `indexable` (a
* mounted folder) or `fetch` (searchable + downloadable, e.g. YouTube). */
export interface SourceInfo {
name: string;
label: string;
kind: 'indexable' | 'fetch';
available: boolean;
}
export interface UploadResponse { export interface UploadResponse {
track_id: string; track_id: string;
title: string; title: string;
already_exists: boolean; already_exists: boolean;
} }
export interface StorageStats { export interface StorageFormatBreakdown {
totalBytes: number; fileFormat: string;
usedBytes: number;
trackCount: number; trackCount: number;
albumCount: number; totalSize: number;
artistCount: number; }
export interface StorageGenreCount {
genre: string;
trackCount: number;
}
/** Capacity of the volume backing the media store. Absent for object-store
* backends (S3), which have no fixed disk to report. */
export interface StorageDiskUsage {
total: number;
used: number;
free: number;
}
export interface StorageStats {
totalTracks: number;
totalArtists: number;
totalAlbums: number;
/** Sum of every track's recorded file size (the library's footprint). */
totalSize: number;
totalDurationSeconds: number;
largestTrackSize: number;
earliestAdded?: string;
latestAdded?: string;
byFormat: StorageFormatBreakdown[];
byMetadataStatus: Record<string, number>;
bySource: Record<string, number>;
topGenres: StorageGenreCount[];
disk?: StorageDiskUsage;
} }
export interface User { export interface User {
@@ -113,6 +203,11 @@ export interface LoginResponse {
tokens: AuthTokens; tokens: AuthTokens;
} }
export interface RegisterRequest {
username: string;
password: string;
}
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
items: T[]; items: T[];
total: number; total: number;
@@ -126,6 +221,8 @@ export interface LibraryFilters {
genre?: string; genre?: string;
artistId?: string; artistId?: string;
albumId?: string; albumId?: string;
/** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */
source?: string;
liked?: boolean; liked?: boolean;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@@ -138,3 +235,26 @@ export interface ApiError {
message: string; message: string;
code?: string; code?: string;
} }
/** One AcoustID candidate from `GET /tracks/{id}/metadata/matches` (§A7). */
export interface MetadataMatch {
acoustid: string;
/** Confidence 0..1. */
score: number;
recordingMbid?: string;
releaseGroupMbid?: string;
title?: string;
artist?: string;
album?: string;
year?: number;
}
/** Manual edits / an accepted match, sent to `PUT /tracks/{id}/metadata`. */
export interface MetadataEdit {
title?: string;
artistName?: string;
albumTitle?: string;
year?: number;
genre?: string;
trackNumber?: number;
}
+4
View File
@@ -15,10 +15,12 @@ import {
ArrowsClockwise, ArrowsClockwise,
CheckCircle, CheckCircle,
Cloud, Cloud,
CloudSlash,
DotsSixVertical, DotsSixVertical,
GearSix, GearSix,
HardDrives, HardDrives,
Heart, Heart,
Info,
MagnifyingGlass, MagnifyingGlass,
Pause, Pause,
Play, Play,
@@ -69,10 +71,12 @@ const ICONS = {
'skip-forward': SkipForward, 'skip-forward': SkipForward,
repeat: Repeat, repeat: Repeat,
heart: Heart, heart: Heart,
info: Info,
'thumbs-down': ThumbsDown, 'thumbs-down': ThumbsDown,
'speaker-high': SpeakerHigh, 'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX, 'speaker-x': SpeakerSimpleX,
cloud: Cloud, cloud: Cloud,
'cloud-slash': CloudSlash,
'check-circle': CheckCircle, 'check-circle': CheckCircle,
'warning-circle': WarningCircle, 'warning-circle': WarningCircle,
'sign-out': SignOut, 'sign-out': SignOut,
+41
View File
@@ -0,0 +1,41 @@
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
/** Single-line text that ping-pong scrolls (like a news ticker) only when it
* overflows its container, otherwise renders as static clipped text. Keeps the
* queue panel from ever growing a horizontal scrollbar on long titles. */
export function Marquee({
text,
className,
}: {
text: string;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const [shift, setShift] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const inner = el.firstElementChild as HTMLElement | null;
const overflow = (inner?.scrollWidth ?? 0) - el.clientWidth;
setShift(overflow > 1 ? overflow : 0);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [text]);
return (
<span
ref={ref}
className={`marquee${shift ? ' on' : ''}${className ? ` ${className}` : ''}`}
style={
shift ? ({ '--mq-shift': `-${shift}px` } as CSSProperties) : undefined
}
>
<span className="marquee-inner">{text}</span>
</span>
);
}
@@ -0,0 +1,22 @@
/*
* "Hopping bars" equalizer indicator (YTM-style) shown next to the currently
* playing track. `animate` controls whether the bars bounce (playback active)
* or sit frozen at full height (paused). Reusable across track lists.
*/
interface Props {
animate?: boolean;
className?: string;
}
export function PlayingIndicator({ animate = true, className }: Props) {
return (
<span
className={`playing-bars${animate ? '' : ' paused'}${className ? ` ${className}` : ''}`}
aria-hidden="true"
>
<span />
<span />
<span />
</span>
);
}
+2
View File
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer'; import { PersistentPlayer } from '../player/PersistentPlayer';
import { QueuePanel } from '../player/QueuePanel'; import { QueuePanel } from '../player/QueuePanel';
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
import { LoadingSkeleton } from '../common/LoadingSkeleton'; import { LoadingSkeleton } from '../common/LoadingSkeleton';
export function AppShell() { export function AppShell() {
@@ -31,6 +32,7 @@ export function AppShell() {
</div> </div>
</main> </main>
<QueuePanel /> <QueuePanel />
<TrackInfoDrawer />
</div> </div>
<PersistentPlayer /> <PersistentPlayer />
</div> </div>
+20 -5
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon'; import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../../hooks/usePermissions'; import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus'; import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth'; import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists'; import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
import { getActiveInstance } from '../../config/instances'; import { getActiveInstance } from '../../config/instances';
@@ -19,9 +19,24 @@ interface NavDef {
const MAIN_NAV: NavDef[] = [ const MAIN_NAV: NavDef[] = [
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' }, { to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' }, {
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' }, to: '/discover',
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' }, labelKey: 'nav.search',
icon: 'magnifying-glass',
perm: 'download',
},
{
to: '/downloads',
labelKey: 'nav.downloads',
icon: 'arrow-circle-down',
perm: 'download',
},
{
to: '/upload',
labelKey: 'nav.upload',
icon: 'upload-simple',
perm: 'upload',
},
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' }, { to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
]; ];
@@ -41,7 +56,7 @@ export function Sidebar() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, isAdmin, hasPermission } = usePermissions(); const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatus(); const status = useConnectionStatusSync();
const { data: playlists } = useGetPlaylistsQuery(); const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance(); const instance = getActiveInstance();
+22 -36
View File
@@ -8,15 +8,14 @@ import {
resume, resume,
toggleMute, toggleMute,
setVolume, setVolume,
toggleShuffle,
setRepeat,
toggleNowPlaying,
toggleQueue, toggleQueue,
} from '../../store/slices/player'; } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { useStreamCached } from '../../hooks/useStreamCached'; import { useStreamCached } from '../../hooks/useStreamCached';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() { export function PersistentPlayer() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,7 +23,11 @@ export function PersistentPlayer() {
const { seek, playNext, playPrev } = useAudioPlayer(); const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player); const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const token = useAppSelector((s) => s.auth.accessToken);
const currentEntry = queue.entries[queue.currentIndex]; const currentEntry = queue.entries[queue.currentIndex];
// Read through to the live Track cache so enrichment updates reach the player,
// not just the play-time snapshot frozen in the queue slice.
const current = useResolvedQueueEntry(currentEntry);
// Source indicator: cached → playing locally, otherwise streaming. // Source indicator: cached → playing locally, otherwise streaming.
const cached = useStreamCached(currentEntry?.trackId); const cached = useStreamCached(currentEntry?.trackId);
@@ -32,41 +35,42 @@ export function PersistentPlayer() {
return <div className="player empty">{t('player.nothingPlaying')}</div>; return <div className="player empty">{t('player.nothingPlaying')}</div>;
} }
const artUrl = getCoverUrl(currentEntry?.albumArtUrl); const artUrl =
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? ''; getCoverUrl(currentEntry?.albumArtUrl) ??
(token && current?.hasCover
? getTrackCoverUrl(current.trackId, token, true)
: undefined);
const seedLabel = current?.albumTitle ?? current?.title ?? '';
const onStream = !cached; const onStream = !cached;
const formatLabel = current?.format?.toUpperCase();
return ( return (
<div className="player"> <div className="player">
<div <div
className="pl-now" className="pl-now"
onClick={() => dispatch(toggleNowPlaying())} onClick={() =>
style={{ cursor: 'pointer' }} currentEntry && dispatch(openTrackInfo(currentEntry.trackId))
}
style={{ cursor: currentEntry ? 'pointer' : 'default' }}
title={currentEntry ? t('trackInfo.open') : undefined}
> >
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} /> <ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
<div className="pl-now-tt"> <div className="pl-now-tt">
<div className="t">{currentEntry?.title ?? '—'}</div> <div className="t">{current?.title ?? '—'}</div>
<div className="a">{currentEntry?.artistName ?? ''}</div> <div className="a">{current?.artistName ?? ''}</div>
<div <div
className="pl-srcbadge" className="pl-srcbadge"
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }} style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
> >
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} /> <Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? t('player.streaming') : t('player.local')} {onStream ? t('player.streaming') : t('player.local')}
{formatLabel && ` · ${formatLabel}`}
</div> </div>
</div> </div>
</div> </div>
<div className="pl-center"> <div className="pl-center">
<div className="pl-transport"> <div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
<button <button
type="button" type="button"
className="pl-tbtn" className="pl-tbtn"
@@ -93,24 +97,6 @@ export function PersistentPlayer() {
> >
<Icon name="skip-forward" fill /> <Icon name="skip-forward" fill />
</button> </button>
<button
type="button"
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
onClick={() =>
dispatch(
setRepeat(
player.repeat === 'none'
? 'all'
: player.repeat === 'all'
? 'one'
: 'none',
),
)
}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
</div> </div>
<div className="pl-seek"> <div className="pl-seek">
<span className="pl-time"> <span className="pl-time">
+201 -59
View File
@@ -1,29 +1,72 @@
import { Slider, Badge } from '@olly/modern-sk'; import {
Slider,
Badge,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
IconButton,
} from '@olly/modern-sk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Icon } from '../common/Icon'; import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile'; import { ArtTile } from '../common/ArtTile';
import { Marquee } from '../common/Marquee';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { import {
goToIndex, goToIndex,
removeFromQueue, removeFromQueue,
moveInQueue,
clearQueue, clearQueue,
toggleShuffle,
toggleLoop,
type QueueEntry,
} from '../../store/slices/queue'; } from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player'; import { toggleQueue } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function QueuePanel() { export function QueuePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
const isOpen = useAppSelector((s) => s.player.isQueueOpen); const isOpen = useAppSelector((s) => s.player.isQueueOpen);
const now = const hasEntries = queue.entries.length > 0;
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const upNext = queue.entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex);
const isRadio = queue.source === 'radio'; const isRadio = queue.source === 'radio';
const sourceLabel = queue.sourceName ?? queue.source; const sourceLabel = queue.sourceName ?? queue.source;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
dispatch(moveInQueue({ from: Number(active.id), to: Number(over.id) }));
};
return ( return (
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}> <aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner"> <div className="qd-inner">
@@ -31,6 +74,22 @@ export function QueuePanel() {
<div className="row"> <div className="row">
<h3>{t('queue.title')}</h3> <h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<button
type="button"
className={`iconbtn sm${queue.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('queue.shuffle')}
>
<Icon name="shuffle" />
</button>
<button
type="button"
className={`iconbtn sm${queue.loop ? ' on' : ''}`}
onClick={() => dispatch(toggleLoop())}
title={t('queue.loop')}
>
<Icon name="repeat" />
</button>
<button <button
type="button" type="button"
className="iconbtn sm" className="iconbtn sm"
@@ -64,32 +123,19 @@ export function QueuePanel() {
</div> </div>
<div className="qd-scroll"> <div className="qd-scroll">
{now ? ( {hasEntries ? (
<> <>
<span
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
{t('queue.nowPlaying')}
</span>
<div className="qd-now">
<ArtTile
seed={now.albumTitle}
size={44}
label={now.albumTitle}
/>
<div className="qt">
<div className="t">{now.title}</div>
<div className="r">{now.artistName}</div>
</div>
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
</div>
{isRadio && ( {isRadio && (
<div className="qd-radio"> <div className="qd-radio">
<div className="row"> <div className="row">
<Icon name="radio" /> <Icon name="radio" />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}> <span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
{t('queue.radioActive')} {t('queue.radioActive')}
</span> </span>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
@@ -116,39 +162,36 @@ export function QueuePanel() {
> >
{t('queue.nextUp')} {t('queue.nextUp')}
</span> </span>
{upNext.length === 0 ? ( <DndContext
<div className="qd-empty">{t('queue.nothingNext')}</div> sensors={sensors}
) : ( collisionDetection={closestCenter}
upNext.map(({ entry, index }) => ( onDragEnd={handleDragEnd}
<div >
key={`${entry.trackId}-${index}`} <SortableContext
className="qrow" items={queue.entries.map((_, index) => String(index))}
onDoubleClick={() => dispatch(goToIndex(index))} strategy={verticalListSortingStrategy}
title={t('queue.doubleClickPlay')} >
> {queue.entries.map((entry, index) => (
<span className="grip"> <QueueRow
<Icon name="dots-six-vertical" /> key={`${entry.trackId}-${index}`}
</span> id={String(index)}
<ArtTile entry={entry}
seed={entry.albumTitle} isCurrent={index === queue.currentIndex}
size={36} isPlaying={isPlaying}
label={entry.albumTitle} onPlay={() => dispatch(goToIndex(index))}
onMoveNext={() =>
dispatch(
moveInQueue({
from: index,
to: queue.currentIndex + 1,
}),
)
}
onRemove={() => dispatch(removeFromQueue(index))}
/> />
<div className="qt"> ))}
<div className="t">{entry.title}</div> </SortableContext>
<div className="r">{entry.artistName}</div> </DndContext>
</div>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
</div>
))
)}
{isRadio && ( {isRadio && (
<div className="qd-loadmore">{t('queue.loadingMore')}</div> <div className="qd-loadmore">{t('queue.loadingMore')}</div>
@@ -162,3 +205,102 @@ export function QueuePanel() {
</aside> </aside>
); );
} }
/** A queue row, resolving its display fields against the live Track cache so
* enrichment updates show. The currently-playing entry is outlined and shows
* a playing-bars indicator in place of the drag grip. */
function QueueRow({
id,
entry,
isCurrent,
isPlaying,
onPlay,
onMoveNext,
onRemove,
}: {
id: string;
entry: QueueEntry;
isCurrent: boolean;
isPlaying: boolean;
onPlay: () => void;
onMoveNext: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const token = useAppSelector((s) => s.auth.accessToken);
const resolved = useResolvedQueueEntry(entry);
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
const artUrl =
getCoverUrl(resolved?.albumArtUrl) ??
(token && resolved?.hasCover
? getTrackCoverUrl(resolved.trackId, token, true)
: undefined);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')}
>
<span className="grip" {...attributes} {...listeners}>
<Icon name="dots-six-vertical" />
</span>
<div className="qart">
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
{isCurrent && (
<div className="cover-playing">
<PlayingIndicator animate={isPlaying} />
</div>
)}
</div>
<div className="qt">
<Marquee className="t" text={resolved?.title ?? entry.title} />
<Marquee
className="r"
text={resolved?.artistName ?? entry.artistName}
/>
</div>
<Menu>
<MenuTrigger asChild>
<IconButton
variant="ghost"
size="sm"
aria-label={t('queue.menu.options')}
>
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={onPlay}>{t('queue.menu.playNow')}</MenuItem>
{!isCurrent && (
<MenuItem onSelect={onMoveNext}>
{t('queue.menu.moveNext')}
</MenuItem>
)}
<MenuItem onSelect={() => dispatch(openTrackInfo(entry.trackId))}>
{t('queue.menu.info')}
</MenuItem>
<MenuItem onSelect={onRemove}>{t('queue.menu.remove')}</MenuItem>
</MenuContent>
</Menu>
</div>
);
}
+55 -5
View File
@@ -1,38 +1,88 @@
import { Badge, Tooltip } from '@olly/modern-sk'; import { Badge, Tooltip } from '@olly/modern-sk';
import { Icon, type IconName } from '../common/Icon';
import type { TrackAvailability } from '../../api/types'; import type { TrackAvailability } from '../../api/types';
/** `TrackAvailability` plus a client-derived state: the backend reports
* `server`, but if it's unreachable and the track's audio is already in the
* offline cache, we know better — show `local` instead. */
export type DisplayAvailability = TrackAvailability | 'local';
interface Props { interface Props {
availability: TrackAvailability; availability: DisplayAvailability;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
} }
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
const CONFIG: Record< const CONFIG: Record<
TrackAvailability, DisplayAvailability,
{ {
label: string; label: string;
variant: 'lime' | 'ember' | 'neutral' | 'outline'; variant: Variant;
icon: IconName;
spin?: boolean;
tooltip: string; tooltip: string;
} }
> = { > = {
server: { server: {
label: 'On server', label: 'On server',
variant: 'lime', variant: 'lime',
icon: 'cloud',
tooltip: 'File available on server', tooltip: 'File available on server',
}, },
local: {
label: 'Local',
variant: 'lime',
icon: 'hard-drives',
tooltip: 'Cached on this device — playable offline',
},
downloading: { downloading: {
label: 'Downloading', label: 'Downloading',
variant: 'neutral', variant: 'neutral',
icon: 'arrows-clockwise',
spin: true,
tooltip: 'Currently downloading', tooltip: 'Currently downloading',
}, },
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' }, error: {
label: 'Error',
variant: 'ember',
icon: 'warning-circle',
tooltip: 'Download failed',
},
missing: { missing: {
label: 'Missing', label: 'Missing',
variant: 'outline', variant: 'outline',
icon: 'cloud-slash',
tooltip: 'File not found on server', tooltip: 'File not found on server',
}, },
}; };
export function AvailabilityBadge({ availability }: Props) { export function AvailabilityBadge({ availability, iconOnly }: Props) {
const cfg = CONFIG[availability]; const cfg = CONFIG[availability];
if (iconOnly) {
return (
<Tooltip content={cfg.tooltip}>
<span style={{ display: 'inline-flex' }}>
<Icon
name={cfg.icon}
className={cfg.spin ? 'spin' : undefined}
style={{ color: COLOR_VAR[cfg.variant], fontSize: 15 }}
/>
</span>
</Tooltip>
);
}
return ( return (
<Tooltip content={cfg.tooltip}> <Tooltip content={cfg.tooltip}>
<Badge variant={cfg.variant} dot> <Badge variant={cfg.variant} dot>
@@ -0,0 +1,82 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import type { MetadataStatus } from '../../api/types';
interface Props {
status: MetadataStatus;
/** Reason shown in the tooltip for a `failed` status. */
error?: string;
/** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
}
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
const VARIANT: Record<MetadataStatus, Variant> = {
pending: 'neutral',
enriched: 'lime',
failed: 'ember',
manual: 'outline',
};
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
const ICON: Record<Exclude<MetadataStatus, 'pending'>, IconName> = {
enriched: 'check-circle',
failed: 'warning-circle',
manual: 'push-pin',
};
/**
* Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
*/
export function MetadataStatusBadge({
status,
error,
hideWhenEnriched = true,
iconOnly,
}: Props) {
const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null;
const label = t(`metadata.status.${status}`);
const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
if (iconOnly) {
return (
<Tooltip content={tooltip}>
<span style={{ display: 'inline-flex' }}>
{status === 'pending' ? (
<Spinner size="sm" />
) : (
<Icon
name={ICON[status]}
style={{ color: COLOR_VAR[VARIANT[status]], fontSize: 15 }}
/>
)}
</span>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
{status === 'pending' ? <Spinner size="sm" /> : null}
{label}
</Badge>
</Tooltip>
);
}
+29 -4
View File
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue'; import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player'; import { play } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import type { Track } from '../../api/types'; import type { Track } from '../../api/types';
interface Props { interface Props {
@@ -42,21 +43,45 @@ export function TrackContextMenu({
return ( return (
<Menu> <Menu>
<MenuTrigger asChild> <MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}> <IconButton
variant="ghost"
size="sm"
aria-label={t('track.menu.options')}
>
</IconButton> </IconButton>
</MenuTrigger> </MenuTrigger>
<MenuContent> <MenuContent>
<MenuItem onSelect={() => { dispatch(play(track.id)); }}> <MenuItem
onSelect={() => {
dispatch(play(track.id));
}}
>
{t('track.menu.playNow')} {t('track.menu.playNow')}
</MenuItem> </MenuItem>
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}> <MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
{t('track.menu.playNext')} {t('track.menu.playNext')}
</MenuItem> </MenuItem>
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}> <MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
{t('track.menu.addToQueue')} {t('track.menu.addToQueue')}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
<MenuItem
onSelect={() => {
dispatch(openTrackInfo(track.id));
}}
>
{t('track.menu.info')}
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && ( {onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}> <MenuItem onSelect={() => onAddToPlaylist(track)}>
{t('track.menu.addToPlaylist')} {t('track.menu.addToPlaylist')}
+319
View File
@@ -0,0 +1,319 @@
import type { ReactNode } from 'react';
import { Badge, Button } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { skipToken } from '@reduxjs/toolkit/query';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { LoadingSkeleton } from '../common/LoadingSkeleton';
import { ErrorState } from '../common/ErrorState';
import { EmptyState } from '../common/EmptyState';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { closeTrackInfo } from '../../store/slices/ui';
import { play } from '../../store/slices/player';
import { addToQueue } from '../../store/slices/queue';
import {
useGetTrackQuery,
useGetAlbumQuery,
} from '../../api/endpoints/library';
import { getTrackCoverUrl } from '../../api/endpoints/streaming';
import {
formatDuration,
formatFileSize,
formatDateTime,
} from '../../lib/format';
import type { Track } from '../../api/types';
/**
* Right-side "Get Info"-style drawer for a single track. Rendered after the
* QueuePanel in AppShell so it sits to the *right* of the queue when both are
* open. Open state lives in `ui.trackInfoId`; it reads the live Track (and its
* album) from the RTKQ cache so enrichment updates stay in sync.
*/
export function TrackInfoDrawer() {
const trackId = useAppSelector((s) => s.ui.trackInfoId);
const isOpen = trackId !== null;
return (
<aside className={`tid${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="tid-inner">
{trackId ? <TrackInfoContent trackId={trackId} /> : null}
</div>
</aside>
);
}
function TrackInfoContent({ trackId }: { trackId: string }) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const token = useAppSelector((s) => s.auth.accessToken);
const {
data: track,
isLoading,
isError,
refetch,
} = useGetTrackQuery(trackId);
// Album record fills in fields the lean TrackOut omits (year especially).
const { data: album } = useGetAlbumQuery(track?.albumId ?? skipToken);
const close = () => dispatch(closeTrackInfo());
return (
<>
<div className="tid-head">
<h3>{t('trackInfo.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={close}
title={t('trackInfo.close')}
>
<Icon name="x" />
</button>
</div>
<div className="tid-scroll">
{isLoading ? (
<LoadingSkeleton rows={6} />
) : isError ? (
<ErrorState onRetry={refetch} />
) : !track ? (
<EmptyState title={t('trackInfo.notFound')} />
) : (
<TrackInfoBody
track={track}
albumYear={album?.year}
albumTrackCount={album?.trackCount}
coverUrl={
token
? getTrackCoverUrl(track.id, token, track.hasCover)
: undefined
}
onPlay={() => dispatch(play(track.id))}
onQueue={() =>
dispatch(
addToQueue({
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
}),
)
}
onEdit={() => {
navigate(`/tracks/${track.id}/metadata`);
}}
/>
)}
</div>
</>
);
}
function TrackInfoBody({
track,
albumYear,
albumTrackCount,
coverUrl,
onPlay,
onQueue,
onEdit,
}: {
track: Track;
albumYear?: number;
albumTrackCount?: number;
coverUrl?: string;
onPlay: () => void;
onQueue: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const seedLabel = track.albumTitle || track.title;
const year = track.year ?? albumYear;
return (
<>
<div className="tid-cover">
{coverUrl ? (
<img src={coverUrl} alt={track.albumTitle} />
) : (
<ArtTile seed={seedLabel} size={256} label={seedLabel} radius={12} />
)}
</div>
<h2 className="tid-title">{track.title}</h2>
<Link className="tid-sub" to={`/artists/${track.artistId}`}>
{track.artistName}
</Link>
{track.albumId && (
<Link className="tid-sub tid-album" to={`/albums/${track.albumId}`}>
<Icon name="vinyl-record" />
{track.albumTitle}
</Link>
)}
<div className="tid-actions">
<Button variant="primary" size="sm" onClick={onPlay}>
<Icon name="play" fill /> {t('trackInfo.play')}
</Button>
<Button variant="ghost" size="sm" onClick={onQueue}>
<Icon name="queue" /> {t('trackInfo.addToQueue')}
</Button>
<Button variant="ghost" size="sm" onClick={onEdit}>
{t('trackInfo.editMetadata')}
</Button>
</div>
<InfoSection title={t('trackInfo.sections.status')}>
<div className="tid-status">
<AvailabilityBadge availability={track.availability} />
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
hideWhenEnriched={false}
/>
{track.liked && (
<Badge variant="lime" dot>
{t('trackInfo.liked')}
</Badge>
)}
</div>
{track.metadataStatus === 'failed' && track.metadataError && (
<p className="tid-error">{track.metadataError}</p>
)}
</InfoSection>
<InfoSection title={t('trackInfo.sections.general')}>
<InfoRow
label={t('trackInfo.fields.artist')}
value={track.artistName}
/>
<InfoRow
label={t('trackInfo.fields.album')}
value={track.albumTitle || undefined}
/>
<InfoRow
label={t('trackInfo.fields.trackNumber')}
value={
track.trackNumber !== undefined
? albumTrackCount
? t('trackInfo.trackOf', {
n: track.trackNumber,
total: albumTrackCount,
})
: String(track.trackNumber)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.disc')}
value={
track.discNumber !== undefined
? String(track.discNumber)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.year')}
value={year !== undefined ? String(year) : undefined}
/>
<InfoRow label={t('trackInfo.fields.genre')} value={track.genre} />
<InfoRow
label={t('trackInfo.fields.duration')}
value={formatDuration(track.durationMs)}
/>
</InfoSection>
<InfoSection title={t('trackInfo.sections.file')}>
<InfoRow
label={t('trackInfo.fields.format')}
value={track.format?.toUpperCase()}
/>
<InfoRow
label={t('trackInfo.fields.bitrate')}
value={
track.bitrate !== undefined
? t('trackInfo.kbps', { n: track.bitrate })
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.size')}
value={
track.fileSize !== undefined
? formatFileSize(track.fileSize)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.source')}
value={track.source}
mono
/>
<InfoRow
label={t('trackInfo.fields.added')}
value={formatDateTime(track.createdAt)}
/>
<InfoRow
label={t('trackInfo.fields.enriched')}
value={formatDateTime(track.enrichedAt)}
/>
</InfoSection>
<InfoSection title={t('trackInfo.sections.identifiers')}>
<InfoRow label={t('trackInfo.fields.trackId')} value={track.id} mono />
<InfoRow
label={t('trackInfo.fields.albumId')}
value={track.albumId || undefined}
mono
/>
<InfoRow
label={t('trackInfo.fields.artistId')}
value={track.artistId}
mono
/>
</InfoSection>
</>
);
}
function InfoSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section className="tid-section">
<span className="msk-label tid-section-label">{title}</span>
{children}
</section>
);
}
/** A label/value row; renders nothing when the value is empty (Finder-style). */
function InfoRow({
label,
value,
mono,
}: {
label: string;
value?: string;
mono?: boolean;
}) {
if (!value) return null;
return (
<div className="tid-row">
<span className="tid-row-k">{label}</span>
<span className={`tid-row-v${mono ? ' mono' : ''}`}>{value}</span>
</div>
);
}
+110 -30
View File
@@ -1,16 +1,26 @@
import { Row } from '@olly/modern-sk'; import { Row } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { TrackContextMenu } from './TrackContextMenu'; import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge'; import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { Icon } from '../common/Icon';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { play } from '../../store/slices/player'; import { useIsOffline } from '../../hooks/useConnectionStatus';
import { useStreamCached } from '../../hooks/useStreamCached';
import { playNow } from '../../store/slices/queue';
import type { Track } from '../../api/types'; import type { Track } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
interface Props { interface Props {
track: Track; track: Track;
index?: number; index?: number;
showAlbum?: boolean; showAlbum?: boolean;
/** Hide cover art and show the track's album position instead — used on
* the album detail page, where the album cover is already shown once in
* the header and per-track art would be redundant. */
hideArt?: boolean;
onAddToPlaylist?: (track: Track) => void; onAddToPlaylist?: (track: Track) => void;
onEditMetadata?: (track: Track) => void; onEditMetadata?: (track: Track) => void;
onDelete?: (track: Track) => void; onDelete?: (track: Track) => void;
@@ -20,56 +30,119 @@ export function TrackRow({
track, track,
index, index,
showAlbum = false, showAlbum = false,
hideArt = false,
onAddToPlaylist, onAddToPlaylist,
onEditMetadata, onEditMetadata,
onDelete, onDelete,
}: Props) { }: Props) {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId); const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying); const isPlaying = useAppSelector((s) => s.player.isPlaying);
const token = useAppSelector((s) => s.auth.accessToken);
const isActive = currentTrackId === track.id; const isActive = currentTrackId === track.id;
const artUrl = getCoverUrl(track.albumArtUrl); // Prefer an explicit album art URL; otherwise serve the track's own cover
// (needs the token in the query string — `<img>` can't send a header).
const artUrl =
getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
// The backend reports `server`, but if it's unreachable and this track's
// audio is already in the offline cache, show "Local" instead.
const offline = useIsOffline();
const cached = useStreamCached(offline ? track.id : undefined);
const displayAvailability =
track.availability === 'server' && offline && cached
? 'local'
: track.availability;
const handlePlayNow = () => {
dispatch(
playNow({
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
}),
);
};
return ( return (
<Row <Row
selected={isActive} selected={isActive}
onDoubleClick={() => dispatch(play(track.id))}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '2rem 2.5rem 1fr auto auto', gridTemplateColumns: hideArt
? '2.5rem 1fr auto auto'
: '2rem 2.5rem 1fr auto auto',
gap: '0.75rem', gap: '0.75rem',
alignItems: 'center', alignItems: 'center',
padding: '0.375rem 0.75rem', padding: '0.375rem 0.75rem',
cursor: 'default', cursor: 'default',
}} }}
> >
<span {!hideArt && (
style={{ <span
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textAlign: 'right',
}}
>
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
</span>
{artUrl ? (
<img
src={artUrl}
alt=""
width={36}
height={36}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : (
<div
style={{ style={{
width: 36, fontSize: '0.75rem',
height: 36, color: 'var(--color-text-3)',
borderRadius: 4, textAlign: 'right',
background: 'var(--color-surface-3)',
}} }}
/> >
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
</span>
)} )}
<div className="track-art">
{hideArt ? (
<div
style={{
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.0625rem',
fontWeight: 600,
color: isActive ? 'var(--color-accent)' : 'var(--color-text-3)',
}}
>
{track.trackNumber ?? (index !== undefined ? index + 1 : '')}
</div>
) : artUrl ? (
<img
src={artUrl}
alt=""
width={36}
height={36}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : (
<div
style={{
width: 36,
height: 36,
borderRadius: 4,
background: 'var(--color-surface-3)',
}}
/>
)}
{isActive && (
<div className="cover-playing">
<PlayingIndicator animate={isPlaying} />
</div>
)}
<button
type="button"
className="track-art-play"
onClick={handlePlayNow}
aria-label={t('track.menu.playNow')}
title={t('track.menu.playNow')}
>
<Icon name="play" fill />
</button>
</div>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div <div
style={{ style={{
@@ -95,7 +168,14 @@ export function TrackRow({
{showAlbum && ` · ${track.albumTitle}`} {showAlbum && ` · ${track.albumTitle}`}
</div> </div>
</div> </div>
<AvailabilityBadge availability={track.availability} /> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
iconOnly
/>
<AvailabilityBadge availability={displayAvailability} iconOnly />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span <span
style={{ style={{
+19
View File
@@ -20,3 +20,22 @@ function runtimeApiBaseUrl(): string | undefined {
export const DEFAULT_API_BASE_URL = export const DEFAULT_API_BASE_URL =
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1'; runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
/**
* Whether the public sign-up UI is shown. Same precedence as the base URL:
* runtime operator config (injected into `window.__APP_CONFIG__` at container
* start) wins over the build-time `PUBLIC_ENABLE_REGISTRATION` env, which
* defaults to enabled. This only gates the *UI*; the backend independently
* enforces `ALLOW_REGISTRATION` and is the real authority.
*/
function parseFlag(value: string | undefined): boolean | undefined {
if (value == null || value === '') return undefined;
return value !== 'false' && value !== '0';
}
export const REGISTRATION_ENABLED: boolean =
(typeof window !== 'undefined'
? window.__APP_CONFIG__?.enableRegistration
: undefined) ??
parseFlag(import.meta.env.PUBLIC_ENABLE_REGISTRATION) ??
true;
+11 -1
View File
@@ -29,8 +29,13 @@ const ACTIVE_KEY = 'mcma:activeInstance';
const LEGACY_URL_KEY = 'mcma_api_base_url'; const LEGACY_URL_KEY = 'mcma_api_base_url';
const LEGACY_AUTH_KEY = 'mcma_auth'; const LEGACY_AUTH_KEY = 'mcma_auth';
// The UI always talks to the `/api/v1` contract, so users only enter the
// origin (and optional reverse-proxy prefix). We append the contract path
// here, the single choke point for both the base URL and the instance id, so
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
function normalizeUrl(url: string): string { function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, ''); const trimmed = url.trim().replace(/\/+$/, '');
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
} }
/** Stable, readable id from a base URL — also serves as the storage namespace. */ /** Stable, readable id from a base URL — also serves as the storage namespace. */
@@ -93,6 +98,11 @@ export function upsertInstance(url: string, name?: string): Instance {
return inst; return inst;
} }
/** Clear a backend's stored session without forgetting the instance itself. */
export function clearInstanceAuth(id: string): void {
localStorage.removeItem(scopedKey('auth', id));
}
/** Remove a backend and wipe every scoped key it owns. */ /** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void { export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id)); writeRegistry(readRegistry().filter((i) => i.id !== id));
+2
View File
@@ -1,6 +1,7 @@
/// <reference types="@rsbuild/core/types" /> /// <reference types="@rsbuild/core/types" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly PUBLIC_API_BASE_URL?: string; readonly PUBLIC_API_BASE_URL?: string;
readonly PUBLIC_ENABLE_REGISTRATION?: string;
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
@@ -11,5 +12,6 @@ interface ImportMeta {
interface Window { interface Window {
__APP_CONFIG__?: { __APP_CONFIG__?: {
apiBaseUrl?: string; apiBaseUrl?: string;
enableRegistration?: boolean;
}; };
} }
+57 -21
View File
@@ -1,6 +1,6 @@
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk'; import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk';
import { import {
useGetAlbumQuery, useGetAlbumQuery,
useGetAlbumTracksQuery, useGetAlbumTracksQuery,
@@ -9,29 +9,56 @@ import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState'; import { ErrorState } from '../../components/common/ErrorState';
import { EmptyState } from '../../components/common/EmptyState'; import { EmptyState } from '../../components/common/EmptyState';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import {
selectLocalAlbums,
selectLocalTracks,
} from '../../store/selectors/localLibrary';
import { setQueue } from '../../store/slices/queue'; import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() { export function AlbumDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>(); const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const token = useAppSelector((s) => s.auth.accessToken);
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId }); const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId }); const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
if (albumQuery.isLoading || tracksQuery.isLoading) { // Offline fallback: resolve the album + its tracks from the locally-cached
return ( // library when the backend is unreachable (same approach as LibraryPage).
<div style={{ padding: '1.5rem' }}> const offline = useIsOffline();
<LoadingSkeleton rows={10} /> const localAlbums = useAppSelector(selectLocalAlbums);
</div> const localTracks = useAppSelector(selectLocalTracks);
);
}
if (albumQuery.isError) { const album =
albumQuery.data ??
(offline ? localAlbums.find((a) => a.id === albumId) : undefined);
const tracks =
tracksQuery.data ??
(offline ? localTracks.filter((tr) => tr.albumId === albumId) : []);
if (!album) {
if (albumQuery.isLoading && !offline) {
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (offline) {
return (
<EmptyState
icon="💿"
title={t('album.offline.title')}
description={t('album.offline.description')}
/>
);
}
return ( return (
<ErrorState <ErrorState
message={t('album.error')} message={t('album.error')}
@@ -39,10 +66,13 @@ export function AlbumDetailPage() {
/> />
); );
} }
// The album record itself carries no cover; fall back to a track's cover.
const album = albumQuery.data; const coverTrack = tracks.find((t) => t.hasCover);
const tracks = tracksQuery.data ?? []; const artUrl =
const artUrl = getCoverUrl(album?.artUrl); getCoverUrl(album?.artUrl) ??
(token && coverTrack
? getTrackCoverUrl(coverTrack.id, token, true)
: undefined);
const handlePlayAll = () => { const handlePlayAll = () => {
if (!tracks.length || !album) return; if (!tracks.length || !album) return;
@@ -65,6 +95,11 @@ export function AlbumDetailPage() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{offline && (
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
<Callout variant="info">{t('common.offlineBanner')}</Callout>
</div>
)}
<div <div
style={{ style={{
padding: '1.25rem 1.5rem', padding: '1.25rem 1.5rem',
@@ -161,16 +196,17 @@ export function AlbumDetailPage() {
</div> </div>
<ScrollArea style={{ flex: 1 }}> <ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />} {tracks.length === 0 && !offline && tracksQuery.isLoading && (
{tracksQuery.isError && ( <LoadingSkeleton rows={10} />
)}
{tracks.length === 0 && !offline && tracksQuery.isError && (
<ErrorState <ErrorState
message={t('album.tracksError')} message={t('album.tracksError')}
onRetry={() => tracksQuery.refetch()} onRetry={() => tracksQuery.refetch()}
/> />
)} )}
{!tracksQuery.isLoading && {tracks.length === 0 &&
!tracksQuery.isError && (offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
tracks.length === 0 && (
<EmptyState <EmptyState
icon="♫" icon="♫"
title={t('album.empty.title')} title={t('album.empty.title')}
@@ -178,7 +214,7 @@ export function AlbumDetailPage() {
/> />
)} )}
{tracks.map((track, i) => ( {tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} /> <TrackRow key={track.id} track={track} index={i} hideArt />
))} ))}
</ScrollArea> </ScrollArea>
</div> </div>
+302 -3
View File
@@ -1,8 +1,307 @@
import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder'; import { ScrollArea, IconButton, Button, Card, Callout } from '@olly/modern-sk';
import {
useGetArtistQuery,
useGetArtistAlbumsQuery,
useGetArtistTracksQuery,
} from '../../api/endpoints/library';
import { TrackRow } from '../../components/track/TrackRow';
import { ArtTile } from '../../components/common/ArtTile';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
import { EmptyState } from '../../components/common/EmptyState';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import {
selectLocalArtists,
selectLocalAlbums,
selectLocalTracks,
} from '../../store/selectors/localLibrary';
import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
import type { Album } from '../../api/types';
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
export function ArtistDetailPage() { export function ArtistDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return <Placeholder title={t('pages.artist')} />; const { artistId } = useParams<{ artistId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const artistQuery = useGetArtistQuery(artistId ?? '', { skip: !artistId });
const albumsQuery = useGetArtistAlbumsQuery(artistId ?? '', {
skip: !artistId,
});
const tracksQuery = useGetArtistTracksQuery(artistId ?? '', {
skip: !artistId,
});
// Offline fallback: resolve the artist + their albums/tracks from the
// locally-cached library when the backend is unreachable.
const offline = useIsOffline();
const localArtists = useAppSelector(selectLocalArtists);
const localAlbums = useAppSelector(selectLocalAlbums);
const localTracks = useAppSelector(selectLocalTracks);
const artist =
artistQuery.data ??
(offline ? localArtists.find((a) => a.id === artistId) : undefined);
const albums =
albumsQuery.data ??
(offline ? localAlbums.filter((a) => a.artistId === artistId) : []);
const tracks =
tracksQuery.data ??
(offline ? localTracks.filter((tr) => tr.artistId === artistId) : []);
if (!artist) {
if (artistQuery.isLoading && !offline) {
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (offline) {
return (
<EmptyState
icon="🎤"
title={t('artist.offline.title')}
description={t('artist.offline.description')}
/>
);
}
return (
<ErrorState
message={t('artist.error')}
onRetry={() => artistQuery.refetch()}
/>
);
}
const handlePlayAll = () => {
if (!tracks.length) return;
dispatch(
setQueue({
entries: tracks.map((tr) => ({
trackId: tr.id,
title: tr.title,
artistName: tr.artistName,
albumTitle: tr.albumTitle,
durationMs: tr.durationMs,
albumArtUrl: tr.albumArtUrl,
})),
source: 'artist',
sourceId: artist.id,
sourceName: artist.name,
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{offline && (
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
<Callout variant="info">{t('common.offlineBanner')}</Callout>
</div>
)}
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label={t('common.back')}
>
</IconButton>
<div
style={{
display: 'flex',
gap: '1.5rem',
alignItems: 'center',
flex: 1,
}}
>
<ArtTile seed={artist.id} label={artist.name} size={96} radius={48} />
<div>
<p
style={{
margin: 0,
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{t('artist.type')}
</p>
<h1
style={{
margin: '0.25rem 0',
fontSize: '1.5rem',
fontWeight: 700,
}}
>
{artist.name}
</h1>
<p
style={{
margin: 0,
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
{t('artist.meta', {
albumCount: artist.albumCount,
trackCount: artist.trackCount,
})}
</p>
</div>
</div>
<Button
variant="primary"
onClick={handlePlayAll}
disabled={!tracks.length}
>
{t('artist.play')}
</Button>
</div>
<ScrollArea style={{ flex: 1 }}>
{/* Discography */}
<section style={{ padding: '1.25rem 1.5rem 0' }}>
<h2
style={{ margin: '0 0 0.75rem', fontSize: '1rem', fontWeight: 600 }}
>
{t('artist.albums')}
</h2>
{albums.length === 0 && !offline && albumsQuery.isLoading && (
<LoadingSkeleton rows={3} height={72} />
)}
{albums.length === 0 && !offline && albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} />
)}
{albums.length === 0 &&
(offline || (!albumsQuery.isLoading && !albumsQuery.isError)) && (
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
{t('artist.noAlbums')}
</p>
)}
{albums.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(9rem, 1fr))',
gap: '1rem',
}}
>
{albums.map((album) => (
<AlbumCard
key={album.id}
album={album}
onClick={() => void navigate(`/albums/${album.id}`)}
/>
))}
</div>
)}
</section>
{/* All tracks */}
<section style={{ padding: '1.5rem 0 0.5rem' }}>
<h2
style={{
margin: '0 1.5rem 0.5rem',
fontSize: '1rem',
fontWeight: 600,
}}
>
{t('artist.tracks')}
</h2>
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
<LoadingSkeleton rows={6} />
)}
{tracks.length === 0 && !offline && tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} />
)}
{tracks.length === 0 &&
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
<EmptyState
icon="♫"
title={t('artist.empty.title')}
description={t('artist.empty.description')}
/>
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} showAlbum />
))}
</section>
</ScrollArea>
</div>
);
}
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const { t } = useTranslation();
const token = useAppSelector((s) => s.auth.accessToken);
const artUrl =
getCoverUrl(album.artUrl) ??
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
return (
<Card
onClick={onClick}
style={{
cursor: 'pointer',
padding: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{artUrl ? (
<img
src={artUrl}
alt={album.title}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: 6,
}}
/>
) : (
<div style={{ width: '100%', aspectRatio: '1' }}>
<ArtTile seed={album.id} label={album.title} size={144} radius={6} />
</div>
)}
<div>
<div
style={{
fontWeight: 600,
fontSize: '0.8125rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.title}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{album.year ? `${album.year} · ` : ''}
{t('library.albumCard.tracksDuration', {
count: album.trackCount,
duration: formatDuration(album.totalDurationMs),
})}
</div>
</div>
</Card>
);
} }
+373 -144
View File
@@ -2,19 +2,50 @@ import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk'; import {
Card,
TextField,
Button,
Callout,
Badge,
Dialog,
IconButton,
} from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon'; import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { setTokens, setUser } from '../../store/slices/auth'; import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config'; import {
import { useLoginMutation } from '../../api/endpoints/auth'; useLoginMutation,
useRegisterMutation,
} from '../../api/endpoints/auth';
import { REGISTRATION_ENABLED } from '../../config/env';
import { import {
listInstances, listInstances,
getActiveInstanceId, getActiveInstanceId,
setActiveInstanceId, setActiveInstanceId,
removeInstance, removeInstance,
clearInstanceAuth,
upsertInstance,
type Instance,
} from '../../config/instances'; } from '../../config/instances';
type Mode = 'login' | 'register';
const HEALTH_VARIANTS = {
connected: 'lime',
connecting: 'neutral',
disconnected: 'ember',
error: 'ember',
} as const;
const HEALTH_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
/** Map an RTKQ login failure to a user-facing i18n key. */ /** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string { function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined; const e = err as FetchBaseQueryError | undefined;
@@ -25,6 +56,137 @@ function resolveLoginError(err: unknown): string {
return 'connect.errors.generic'; return 'connect.errors.generic';
} }
/** Map an RTKQ register failure to a user-facing i18n key. */
function resolveRegisterError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 409) return 'connect.errors.usernameTaken';
if (e.status === 422) return 'connect.errors.passwordTooShort';
if (e.status === 403) return 'connect.errors.registrationDisabled';
}
return 'connect.errors.registerFailed';
}
function InstanceRow({
inst,
selected,
onSelect,
onLogout,
onRemove,
}: {
inst: Instance;
selected: boolean;
onSelect: () => void;
onLogout: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const status = useConnectionStatus(inst.baseUrl);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<Badge variant={HEALTH_VARIANTS[status]} dot>
{t(HEALTH_KEY[status])}
</Badge>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{selected ? (
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
) : (
<Button variant="ghost" size="sm" onClick={onSelect}>
{t('connect.domains.use')}
</Button>
)}
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={t('connect.removeDialog.title')}
description={t('connect.removeDialog.description', {
name: inst.name,
})}
trigger={
<button
type="button"
className="iconbtn sm"
title={t('connect.domains.forgetTitle')}
>
<Icon name="trash" />
</button>
}
footer={
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
}}
>
<Button
variant="ghost"
size="sm"
onClick={() => setDialogOpen(false)}
>
{t('connect.removeDialog.cancel')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDialogOpen(false);
onLogout();
}}
>
{t('connect.removeDialog.logout')}
</Button>
<Button
variant="ember"
size="sm"
onClick={() => {
setDialogOpen(false);
onRemove();
}}
>
{t('connect.removeDialog.removeAndLogout')}
</Button>
</div>
}
/>
</div>
);
}
export function ConnectPage() { export function ConnectPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -32,40 +194,79 @@ export function ConnectPage() {
const [rev, setRev] = useState(0); const [rev, setRev] = useState(0);
const instances = listInstances(); const instances = listInstances();
const activeId = getActiveInstanceId();
const [apiUrl, setApiUrl] = useState('https://'); const [selectedId, setSelectedId] = useState<string | null>(() =>
getActiveInstanceId(),
);
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
const [instanceAddShown, setInstanceAddShown] = useState(false);
const [addUrl, setAddUrl] = useState('');
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [login, { isLoading }] = useLoginMutation(); const [login, { isLoading: isLoggingIn }] = useLoginMutation();
const [register, { isLoading: isRegistering }] = useRegisterMutation();
const isLoading = isLoggingIn || isRegistering;
const switchTo = (id: string) => { const switchMode = (next: Mode) => {
setMode(next);
setError(null);
};
// Switching the active instance and reloading lets the app pick the saved
// session for that instance back up (if any); if it has none, ProtectedRoute
// bounces back here and `selectedId` defaults to it, surfacing the login card.
const selectInstance = (id: string) => {
setActiveInstanceId(id); setActiveInstanceId(id);
window.location.assign('/'); window.location.assign('/');
}; };
const forget = (id: string) => { const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const url = addUrl.trim();
if (!url || url === 'https://') return;
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
setAddUrl('https://');
setSelectedId(inst.id);
setRev((r) => r + 1);
};
const handleLogout = (id: string) => {
clearInstanceAuth(id);
setRev((r) => r + 1);
};
const handleRemove = (id: string) => {
removeInstance(id); removeInstance(id);
if (selectedId === id) setSelectedId(getActiveInstanceId());
setRev((r) => r + 1); setRev((r) => r + 1);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedInstance) return;
setError(null); setError(null);
// Point the API layer at this backend *before* logging in — baseQuery reads
// the active instance's URL at request time. Auth tokens then persist under
// that instance's namespace, never bleeding across servers.
setApiBaseUrl(apiUrl);
try { try {
const { user, tokens } = await login({ username, password }).unwrap(); const action =
mode === 'register'
? register({ username, password })
: login({ username, password });
const { user, tokens } = await action.unwrap();
dispatch(setTokens(tokens)); dispatch(setTokens(tokens));
dispatch(setUser(user)); dispatch(setUser(user));
void navigate('/'); void navigate('/');
} catch (err) { } catch (err) {
setError(resolveLoginError(err)); setError(
mode === 'register'
? resolveRegisterError(err)
: resolveLoginError(err),
);
} }
}; };
@@ -111,144 +312,172 @@ export function ConnectPage() {
<Icon name="vinyl-record" fill /> MCMA <Icon name="vinyl-record" fill /> MCMA
</h1> </h1>
{instances.length > 0 && (
<Card>
<div
style={{
padding: '1.25rem 1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
{t('connect.savedInstances')}
</span>
{instances.map((inst) => (
<div
key={inst.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">{t('connect.active')}</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
{t('connect.use')}
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title={t('connect.forgetTitle')}
>
<Icon name="trash" />
</button>
</div>
))}
</div>
</Card>
)}
<Card> <Card>
<form <div
onSubmit={handleSubmit}
style={{ style={{
padding: '1.25rem 1.5rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '1rem', gap: '0.5rem',
padding: '1.5rem',
}} }}
> >
<span className="msk-label">{t('connect.form.title')}</span> {instances.length > 0 && (
<div> <>
<label style={labelStyle}>{t('connect.form.serverUrl')}</label> <span className="msk-label" style={{ marginBottom: '0.25rem' }}>
<TextField {t('connect.domains.title')}
value={apiUrl} </span>
onChange={(e) => setApiUrl(e.target.value)} {instances.map((inst) => (
placeholder="https://your-server.example.com/api/v1" <InstanceRow
required key={inst.id}
/> inst={inst}
</div> selected={inst.id === selectedId}
<div> onSelect={() => selectInstance(inst.id)}
<label style={labelStyle}>{t('connect.form.username')}</label> onLogout={() => handleLogout(inst.id)}
<TextField onRemove={() => handleRemove(inst.id)}
value={username} />
onChange={(e) => setUsername(e.target.value)} ))}
placeholder="username" </>
autoComplete="username" )}
required <form
/> onSubmit={handleAdd}
</div> style={{
<div> display: 'flex',
<label style={labelStyle}>{t('connect.form.password')}</label> gap: '0.5rem',
<TextField marginTop: instances.length > 0 ? '0.5rem' : 0,
type="password" }}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete="current-password"
required
/>
</div>
{error && <Callout variant="danger">{t(error)}</Callout>}
<Button
type="submit"
variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
> >
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')} {instanceAddShown ? (
</Button> <>
</form> <TextField
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder={t('connect.domains.addPlaceholder')}
style={{ flex: 1 }}
/>
<IconButton type="submit" variant="primary">
<Icon name="plus" />
</IconButton>
</>
) : (
<Button
onClick={() => setInstanceAddShown(true)}
style={{ width: '100%' }}
variant="ghost"
>
<Icon name="plus" /> {t('connect.domains.addButton')}
</Button>
)}
</form>
</div>
</Card> </Card>
{selectedInstance && (
<Card>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1.5rem',
}}
>
<span className="msk-label">
{mode === 'register'
? t('connect.login.registerTitle', {
name: selectedInstance.name,
})
: t('connect.login.title', { name: selectedInstance.name })}
</span>
<div>
<label style={labelStyle}>{t('connect.login.username')}</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="username"
autoComplete="username"
required
/>
</div>
<div>
<label style={labelStyle}>{t('connect.login.password')}</label>
<TextField
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete={
mode === 'register' ? 'new-password' : 'current-password'
}
required
/>
{mode === 'register' && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('connect.login.passwordHint')}
</span>
)}
</div>
{error && <Callout variant="danger">{t(error)}</Callout>}
<Button
type="submit"
variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
>
{isLoading
? mode === 'register'
? t('connect.login.registering')
: t('connect.login.submitting')
: mode === 'register'
? t('connect.login.registerSubmit')
: t('connect.login.submit')}
</Button>
{REGISTRATION_ENABLED && (
<div
style={{
textAlign: 'center',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{mode === 'register' ? (
<>
{t('connect.login.haveAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('login')}
>
{t('connect.login.signInLink')}
</Button>
</>
) : (
<>
{t('connect.login.noAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('register')}
>
{t('connect.login.registerLink')}
</Button>
</>
)}
</div>
)}
</form>
</Card>
)}
</div> </div>
</div> </div>
); );
@@ -1,13 +1,275 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import {
Badge,
Button,
Callout,
Progress,
ScrollArea,
Spinner,
} from '@olly/modern-sk';
import {
useCancelDownloadMutation,
useGetDownloadsQuery,
useRetryDownloadMutation,
} from '../../api/endpoints/downloads';
import { Icon } from '../../components/common/Icon';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { formatDateTime } from '../../lib/format';
import type { DownloadJob, DownloadStatus } from '../../api/types';
const ACTIVE: readonly DownloadStatus[] = [
'queued',
'downloading',
'enriching',
];
const isActive = (s: DownloadStatus) => ACTIVE.includes(s);
const STATUS_BADGE: Record<
DownloadStatus,
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
> = {
queued: { variant: 'neutral', key: 'downloads.status.queued' },
downloading: { variant: 'neutral', key: 'downloads.status.downloading' },
enriching: { variant: 'outline', key: 'downloads.status.enriching' },
done: { variant: 'lime', key: 'downloads.status.done' },
failed: { variant: 'ember', key: 'downloads.status.failed' },
};
/** `/downloads` — A5 download manager: active queue, progress, errors, retries. */
export function DownloadsManagerPage() { export function DownloadsManagerPage() {
const { t } = useTranslation(); const { t } = useTranslation();
// Poll while anything is in flight; idle pages stop polling (tag invalidation
// from a new download still refetches and re-arms the interval).
const [pollMs, setPollMs] = useState(2000);
const { data, isLoading, isError, refetch } = useGetDownloadsQuery(
undefined,
{
pollingInterval: pollMs,
},
);
useEffect(() => {
const anyActive = data?.items.some((j) => isActive(j.status)) ?? false;
setPollMs(anyActive ? 1500 : 0);
}, [data]);
const jobs = data?.items ?? [];
const active = jobs.filter((j) => isActive(j.status));
const finished = jobs.filter((j) => !isActive(j.status));
const failedCount = jobs.filter((j) => j.status === 'failed').length;
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Window title={t('pages.downloads')}> <header
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p> style={{
</Window> padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<div>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('downloads.title')}
</h2>
<p
style={{
margin: '0.25rem 0 0',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{t('downloads.subtitle')}
</p>
</div>
{active.length > 0 && (
<Badge variant="neutral">
<Spinner size="sm" />{' '}
{t('downloads.activeCount', { count: active.length })}
</Badge>
)}
</header>
<ScrollArea style={{ flex: 1 }}>
<div
style={{
padding: '1.5rem',
maxWidth: 880,
margin: '0 auto',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
{isLoading && <LoadingSkeleton rows={5} height={64} />}
{isError && (
<ErrorState
message={t('downloads.loadError')}
onRetry={() => refetch()}
/>
)}
{data && jobs.length === 0 && (
<EmptyState
icon={<Icon name="arrow-circle-down" />}
title={t('downloads.emptyTitle')}
description={t('downloads.emptyDesc')}
/>
)}
{failedCount > 0 && (
<Callout variant="warning">
{t('downloads.failedBanner', { count: failedCount })}
</Callout>
)}
{active.length > 0 && (
<Section title={t('downloads.sectionActive')}>
{active.map((job) => (
<JobRow key={job.id} job={job} />
))}
</Section>
)}
{finished.length > 0 && (
<Section title={t('downloads.sectionHistory')}>
{finished.map((job) => (
<JobRow key={job.id} job={job} />
))}
</Section>
)}
</div>
</ScrollArea>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
<span
style={{
fontWeight: 600,
fontSize: '0.8125rem',
color: 'var(--color-text-2)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{title}
</span>
{children}
</section>
);
}
function JobRow({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const navigate = useNavigate();
const [cancel, { isLoading: cancelling }] = useCancelDownloadMutation();
const [retry, { isLoading: retrying }] = useRetryDownloadMutation();
const badge = STATUS_BADGE[job.status];
const label = job.query || job.sourceId || job.source;
const added = formatDateTime(job.createdAt);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-surface-1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.9375rem',
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={label}
>
{label}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{job.source}
{job.retryCount > 0 &&
` · ${t('downloads.attempt', { count: job.retryCount + 1 })}`}
{added && ` · ${added}`}
</div>
</div>
<Badge variant={badge.variant} dot>
{t(badge.key)}
</Badge>
{job.status === 'done' && job.trackId && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => void navigate(`/tracks/${job.trackId}/metadata`)}
>
{t('downloads.open')}
</Button>
)}
{job.status === 'failed' && (
<Button
variant="ghost"
size="sm"
type="button"
disabled={retrying}
onClick={() => void retry(job.id)}
>
<Icon name="arrows-clockwise" /> {t('downloads.retry')}
</Button>
)}
{isActive(job.status) && (
<Button
variant="ghost"
size="sm"
type="button"
disabled={cancelling}
onClick={() => void cancel(job.id)}
title={t('downloads.cancel')}
>
<Icon name="x" />
</Button>
)}
</div>
{job.status === 'downloading' && (
<Progress value={Math.round(job.progress * 100)} />
)}
{job.status === 'failed' && job.errorMessage && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{job.errorMessage}
</div>
)}
</div> </div>
); );
} }
+188 -71
View File
@@ -1,13 +1,14 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Tabs, Tabs,
TabsList, TabsList,
TabsContent, TabsContent,
SearchField,
ScrollArea, ScrollArea,
Card, Card,
TextField,
Callout,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
import { import {
useGetTracksQuery, useGetTracksQuery,
@@ -18,11 +19,32 @@ import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState'; import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState'; import { ErrorState } from '../../components/common/ErrorState';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import {
selectLocalTracks,
selectLocalAlbums,
selectLocalArtists,
} from '../../store/selectors/localLibrary';
import { setQueue } from '../../store/slices/queue'; import { setQueue } from '../../store/slices/queue';
import type { Track, Album, Artist } from '../../api/types'; import type { Track, Album, Artist } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { useDebounce } from 'use-debounce';
/** Case-insensitive substring match used for client-side search while offline
* (the server can't run the query, so we filter the locally-cached library). */
function matchTrack(tr: Track, q: string): boolean {
return (
tr.title.toLowerCase().includes(q) ||
tr.artistName.toLowerCase().includes(q) ||
tr.albumTitle.toLowerCase().includes(q)
);
}
const matchAlbum = (a: Album, q: string): boolean =>
a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q);
const matchArtist = (a: Artist, q: string): boolean =>
a.name.toLowerCase().includes(q);
export function LibraryPage() { export function LibraryPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,10 +52,63 @@ export function LibraryPage() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks'); const [tab, setTab] = useState('tracks');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300);
const tracksQuery = useGetTracksQuery(search ? { search } : undefined); // Poll while any listed track is still being enriched, then stop. Enrichment
const albumsQuery = useGetAlbumsQuery(search ? { search } : undefined); // runs asynchronously in a worker after import/upload; without this the row
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined); // stays stuck on "Identifying metadata…" until something else invalidates the
// Track tag. Cleared to 0 once nothing is pending (and while offline).
const [tracksPollMs, setTracksPollMs] = useState(0);
const tracksQuery = useGetTracksQuery(
debouncedSearch ? { search } : undefined,
{ pollingInterval: tracksPollMs },
);
const albumsQuery = useGetAlbumsQuery(
debouncedSearch ? { search } : undefined,
);
const artistsQuery = useGetArtistsQuery(
debouncedSearch ? { search } : undefined,
);
// Offline fallback: when the backend is unreachable, compose the library from
// whatever the RTKQ cache holds locally (rehydrated last-seen + this session),
// filtering client-side since the server can't run the search.
const offline = useIsOffline();
const localTracks = useAppSelector(selectLocalTracks);
const localAlbums = useAppSelector(selectLocalAlbums);
const localArtists = useAppSelector(selectLocalArtists);
const q = debouncedSearch.trim().toLowerCase();
const anyPending =
!offline &&
(tracksQuery.data?.items.some((tr) => tr.metadataStatus === 'pending') ??
false);
useEffect(() => {
setTracksPollMs(anyPending ? 4000 : 0);
}, [anyPending]);
// Live server data wins; offline we fall back to the locally-composed list.
const tracksToShow =
tracksQuery.data?.items ??
(offline
? q
? localTracks.filter((tr) => matchTrack(tr, q))
: localTracks
: undefined);
const albumsToShow =
albumsQuery.data?.items ??
(offline
? q
? localAlbums.filter((a) => matchAlbum(a, q))
: localAlbums
: undefined);
const artistsToShow =
artistsQuery.data?.items ??
(offline
? q
? localArtists.filter((a) => matchArtist(a, q))
: localArtists
: undefined);
const handlePlayAll = (tracks: Track[]) => { const handlePlayAll = (tracks: Track[]) => {
dispatch( dispatch(
@@ -68,15 +143,20 @@ export function LibraryPage() {
{t('library.title')} {t('library.title')}
</h2> </h2>
<div style={{ flex: 1, maxWidth: '20rem' }}> <div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField <TextField
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('library.searchPlaceholder')} placeholder={t('library.searchPlaceholder')}
icon="⌕"
/> />
</div> </div>
</div> </div>
{offline && (
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
<Callout variant="info">{t('library.offline.banner')}</Callout>
</div>
)}
<Tabs <Tabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
@@ -105,74 +185,84 @@ export function LibraryPage() {
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}> <TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}> <ScrollArea style={{ height: '100%' }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />} {!tracksToShow && tracksQuery.isLoading && (
{tracksQuery.isError && ( <LoadingSkeleton rows={12} />
)}
{!tracksToShow && !offline && tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} /> <ErrorState onRetry={() => tracksQuery.refetch()} />
)} )}
{tracksQuery.data && tracksQuery.data.items.length === 0 && ( {tracksToShow && tracksToShow.length === 0 && (
<EmptyState <EmptyState
icon="♫" icon="♫"
title={t('library.empty.tracks.title')} title={t(
description={t('library.empty.tracks.description')} offline
? 'library.offline.emptyTitle'
: 'library.empty.tracks.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.tracks.description',
)}
/> />
)} )}
{tracksQuery.data && {tracksToShow && tracksToShow.length > 0 && (
tracksQuery.data.items.length > 0 && <div>
(() => { <div
const data = tracksQuery.data!; style={{
return ( padding: '0.5rem 0.75rem',
<div> display: 'flex',
<div gap: '0.5rem',
style={{ alignItems: 'center',
padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--color-border)',
display: 'flex', }}
gap: '0.5rem', >
alignItems: 'center', <button
borderBottom: '1px solid var(--color-border)', onClick={() => handlePlayAll(tracksToShow)}
}} style={{
> background: 'none',
<button border: 'none',
onClick={() => handlePlayAll(data.items)} cursor: 'pointer',
style={{ color: 'var(--color-accent)',
background: 'none', fontSize: '0.875rem',
border: 'none', fontWeight: 500,
cursor: 'pointer', }}
color: 'var(--color-accent)', >
fontSize: '0.875rem', {t('library.playAll', { count: tracksToShow.length })}
fontWeight: 500, </button>
}} </div>
> {tracksToShow.map((track, i) => (
{t('library.playAll', { count: data.total })} <TrackRow key={track.id} track={track} index={i} showAlbum />
</button> ))}
</div> </div>
{data.items.map((track, i) => ( )}
<TrackRow
key={track.id}
track={track}
index={i}
showAlbum
/>
))}
</div>
);
})()}
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}> <TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}> <ScrollArea style={{ height: '100%' }}>
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />} {!albumsToShow && albumsQuery.isLoading && (
{albumsQuery.isError && ( <LoadingSkeleton rows={8} height={72} />
)}
{!albumsToShow && !offline && albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} /> <ErrorState onRetry={() => albumsQuery.refetch()} />
)} )}
{albumsQuery.data && albumsQuery.data.items.length === 0 && ( {albumsToShow && albumsToShow.length === 0 && (
<EmptyState <EmptyState
icon="💿" icon="💿"
title={t('library.empty.albums.title')} title={t(
description={t('library.empty.albums.description')} offline
? 'library.offline.emptyTitle'
: 'library.empty.albums.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.albums.description',
)}
/> />
)} )}
{albumsQuery.data && ( {albumsToShow && albumsToShow.length > 0 && (
<div <div
style={{ style={{
display: 'grid', display: 'grid',
@@ -181,7 +271,7 @@ export function LibraryPage() {
padding: '1.25rem 1.5rem', padding: '1.25rem 1.5rem',
}} }}
> >
{albumsQuery.data.items.map((album) => ( {albumsToShow.map((album) => (
<AlbumCard <AlbumCard
key={album.id} key={album.id}
album={album} album={album}
@@ -195,21 +285,35 @@ export function LibraryPage() {
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}> <TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}> <ScrollArea style={{ height: '100%' }}>
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />} {!artistsToShow && artistsQuery.isLoading && (
{artistsQuery.isError && ( <LoadingSkeleton rows={8} />
)}
{!artistsToShow && !offline && artistsQuery.isError && (
<ErrorState onRetry={() => artistsQuery.refetch()} /> <ErrorState onRetry={() => artistsQuery.refetch()} />
)} )}
{artistsQuery.data && artistsQuery.data.items.length === 0 && ( {artistsToShow && artistsToShow.length === 0 && (
<EmptyState <EmptyState
icon="🎤" icon="🎤"
title={t('library.empty.artists.title')} title={t(
description={t('library.empty.artists.description')} offline
? 'library.offline.emptyTitle'
: 'library.empty.artists.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.artists.description',
)}
/> />
)} )}
{artistsQuery.data && ( {artistsToShow && artistsToShow.length > 0 && (
<div style={{ padding: '0.5rem 0' }}> <div style={{ padding: '0.5rem 0' }}>
{artistsQuery.data.items.map((artist) => ( {artistsToShow.map((artist) => (
<ArtistRow key={artist.id} artist={artist} /> <ArtistRow
key={artist.id}
artist={artist}
onClick={() => void navigate(`/artists/${artist.id}`)}
/>
))} ))}
</div> </div>
)} )}
@@ -222,7 +326,12 @@ export function LibraryPage() {
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) { function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const artUrl = getCoverUrl(album.artUrl); const token = useAppSelector((s) => s.auth.accessToken);
// The album record has no cover URL; build one from `hasCover` (served by
// GET /albums/{id}/cover, token in the query — <img> can't send a header).
const artUrl =
getCoverUrl(album.artUrl) ??
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
return ( return (
<Card <Card
onClick={onClick} onClick={onClick}
@@ -295,15 +404,23 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
); );
} }
function ArtistRow({ artist }: { artist: Artist }) { function ArtistRow({
artist,
onClick,
}: {
artist: Artist;
onClick: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
onClick={onClick}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '0.75rem', gap: '0.75rem',
padding: '0.5rem 1.5rem', padding: '0.5rem 1.5rem',
cursor: 'pointer',
}} }}
> >
<div <div
@@ -1,4 +1,25 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Callout,
Card,
IconButton,
ScrollArea,
Spinner,
TextField,
} from '@olly/modern-sk';
import {
useApplyMetadataMutation,
useEnrichTrackMutation,
useGetTrackQuery,
useLazyGetMetadataMatchesQuery,
} from '../../api/endpoints/library';
import type { MetadataMatch } from '../../api/types';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
import { Placeholder } from '../../components/common/Placeholder'; import { Placeholder } from '../../components/common/Placeholder';
interface Props { interface Props {
@@ -6,13 +27,565 @@ interface Props {
batch?: boolean; batch?: boolean;
} }
/** Editable fields, kept as strings while in the form — parsed on save. */
interface FormState {
title: string;
artistName: string;
albumTitle: string;
year: string;
genre: string;
trackNumber: string;
}
const EMPTY_FORM: FormState = {
title: '',
artistName: '',
albumTitle: '',
year: '',
genre: '',
trackNumber: '',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
function fieldStyle(): React.CSSProperties {
return { width: '100%' };
}
/** /**
* `/tracks/:trackId/metadata` (single) and `/metadata/batch` (bulk) — A7 * `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
* metadata editor with auto-enrichment / diff view. Scaffold only. * match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
*/ */
export function MetadataEditorPage({ batch = false }: Props) { export function MetadataEditorPage({ batch = false }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
if (batch) {
return <Placeholder title={t('pages.metadataBatch')} />;
}
return <SingleTrackEditor />;
}
function SingleTrackEditor() {
const { t } = useTranslation();
const navigate = useNavigate();
const { trackId } = useParams<{ trackId: string }>();
const trackQuery = useGetTrackQuery(trackId ?? '', { skip: !trackId });
const [findMatches, matchesResult] = useLazyGetMetadataMatchesQuery();
const [applyMetadata, applyResult] = useApplyMetadataMutation();
const [enrichTrack, enrichResult] = useEnrichTrackMutation();
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [initialized, setInitialized] = useState(false);
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(
null,
);
// Seed the form from the loaded track exactly once — afterwards it's the
// user's edit buffer and shouldn't be clobbered by refetches.
useEffect(() => {
if (initialized || !trackQuery.data) return;
const track = trackQuery.data;
setForm({
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
year: track.year != null ? String(track.year) : '',
genre: track.genre ?? '',
trackNumber: track.trackNumber != null ? String(track.trackNumber) : '',
});
setInitialized(true);
}, [initialized, trackQuery.data]);
if (!trackId) {
return <ErrorState message={t('metadataEditor.error')} />;
}
if (trackQuery.isLoading || !initialized) {
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={6} />
</div>
);
}
if (trackQuery.isError || !trackQuery.data) {
return (
<ErrorState
message={t('metadataEditor.error')}
onRetry={() => trackQuery.refetch()}
/>
);
}
const track = trackQuery.data;
const updateField = (key: keyof FormState) => (value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const applyMatch = (match: MetadataMatch) => {
setForm((prev) => ({
...prev,
title: match.title ?? prev.title,
artistName: match.artist ?? prev.artistName,
albumTitle: match.album ?? prev.albumTitle,
year: match.year != null ? String(match.year) : prev.year,
}));
setSelectedMatch(null);
};
const handleSave = async () => {
await applyMetadata({
trackId,
edit: {
title: form.title.trim() || undefined,
artistName: form.artistName.trim() || undefined,
albumTitle: form.albumTitle.trim() || undefined,
year: form.year.trim() ? Number(form.year) : undefined,
genre: form.genre.trim() || undefined,
trackNumber: form.trackNumber.trim()
? Number(form.trackNumber)
: undefined,
},
}).unwrap();
};
return ( return (
<Placeholder title={batch ? t('pages.metadataBatch') : t('pages.metadata')} /> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label={t('common.back')}
>
</IconButton>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('pages.metadata')}
</h2>
<p
style={{
margin: '0.125rem 0 0',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{track.artistName} · {track.title}
</p>
</div>
</div>
<ScrollArea style={{ flex: 1 }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
maxWidth: 640,
}}
>
{applyResult.isSuccess && (
<Callout variant="success">{t('metadataEditor.saved')}</Callout>
)}
{applyResult.isError && (
<Callout variant="danger">{t('metadataEditor.saveError')}</Callout>
)}
<Card>
<div
style={{
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<div>
<label style={labelStyle}>
{t('metadataEditor.fields.title')}
</label>
<TextField
style={fieldStyle()}
value={form.title}
onChange={(e) => updateField('title')(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>
{t('metadataEditor.fields.artist')}
</label>
<TextField
style={fieldStyle()}
value={form.artistName}
onChange={(e) => updateField('artistName')(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>
{t('metadataEditor.fields.album')}
</label>
<TextField
style={fieldStyle()}
value={form.albumTitle}
onChange={(e) => updateField('albumTitle')(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>
{t('metadataEditor.fields.year')}
</label>
<TextField
style={fieldStyle()}
type="number"
value={form.year}
onChange={(e) => updateField('year')(e.target.value)}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>
{t('metadataEditor.fields.trackNumber')}
</label>
<TextField
style={fieldStyle()}
type="number"
value={form.trackNumber}
onChange={(e) => updateField('trackNumber')(e.target.value)}
/>
</div>
</div>
<div>
<label style={labelStyle}>
{t('metadataEditor.fields.genre')}
</label>
<TextField
style={fieldStyle()}
value={form.genre}
onChange={(e) => updateField('genre')(e.target.value)}
/>
</div>
<div
style={{
display: 'flex',
gap: '0.5rem',
justifyContent: 'flex-end',
}}
>
<Button
variant="primary"
onClick={() => void handleSave()}
disabled={applyResult.isLoading}
>
{applyResult.isLoading ? (
<Spinner size="sm" />
) : (
t('metadataEditor.save')
)}
</Button>
</div>
</div>
</Card>
<Card>
<div
style={{
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.75rem',
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
{t('metadataEditor.autoEnrich.title')}
</div>
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{t('metadataEditor.autoEnrich.hint')}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<Button
variant="ghost"
size="sm"
onClick={() => void enrichTrack(trackId)}
disabled={enrichResult.isLoading}
>
{enrichResult.isLoading ? (
<Spinner size="sm" />
) : (
t('metadataEditor.autoEnrich.reEnrich')
)}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => void findMatches(trackId)}
disabled={matchesResult.isFetching}
>
{matchesResult.isFetching ? (
<Spinner size="sm" />
) : (
t('metadataEditor.autoEnrich.findMatches')
)}
</Button>
</div>
</div>
{enrichResult.isSuccess && (
<Callout variant="info">
{t('metadataEditor.autoEnrich.enqueued')}
</Callout>
)}
{matchesResult.isError && (
<Callout variant="danger">
{t('metadataEditor.autoEnrich.error')}
</Callout>
)}
{matchesResult.isSuccess &&
matchesResult.data &&
(matchesResult.data.length === 0 ? (
<Callout variant="warning">
{t('metadataEditor.autoEnrich.noMatches')}
</Callout>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{matchesResult.data.map((match) => (
<MatchRow
key={match.acoustid}
match={match}
onUse={() => setSelectedMatch(match)}
/>
))}
</div>
))}
{selectedMatch && (
<DiffView
current={form}
proposed={selectedMatch}
onApply={() => applyMatch(selectedMatch)}
onCancel={() => setSelectedMatch(null)}
/>
)}
</div>
</Card>
</div>
</ScrollArea>
</div>
);
}
function MatchRow({
match,
onUse,
}: {
match: MetadataMatch;
onUse: () => void;
}) {
const { t } = useTranslation();
const pct = Math.round(match.score * 100);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-surface-1)',
}}
>
<Badge variant={pct >= 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}>
{pct}%
</Badge>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{match.title ?? t('metadataEditor.matches.unknownTitle')}
</div>
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{[match.artist, match.album, match.year]
.filter((v) => v !== undefined && v !== null && v !== '')
.join(' · ')}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onUse}>
{t('metadataEditor.matches.use')}
</Button>
</div>
);
}
interface DiffRowDef {
key: 'title' | 'artistName' | 'albumTitle' | 'year';
label: string;
current: string;
proposed?: string;
}
function DiffView({
current,
proposed,
onApply,
onCancel,
}: {
current: FormState;
proposed: MetadataMatch;
onApply: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const rows: DiffRowDef[] = [
{
key: 'title',
label: t('metadataEditor.fields.title'),
current: current.title,
proposed: proposed.title,
},
{
key: 'artistName',
label: t('metadataEditor.fields.artist'),
current: current.artistName,
proposed: proposed.artist,
},
{
key: 'albumTitle',
label: t('metadataEditor.fields.album'),
current: current.albumTitle,
proposed: proposed.album,
},
{
key: 'year',
label: t('metadataEditor.fields.year'),
current: current.year,
proposed: proposed.year != null ? String(proposed.year) : undefined,
},
];
const changed = rows.filter(
(row) => row.proposed !== undefined && row.proposed !== row.current,
);
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '0.875rem 1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.625rem',
background: 'var(--color-surface-1)',
}}
>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>
{t('metadataEditor.diff.title')}
</div>
{changed.length === 0 ? (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('metadataEditor.diff.noChanges')}
</div>
) : (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}
>
{changed.map((row) => (
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
<span style={{ color: 'var(--color-text-3)' }}>
{row.label}:{' '}
</span>
<span
style={{
textDecoration: 'line-through',
color: 'var(--color-text-3)',
}}
>
{row.current || '—'}
</span>
{' → '}
<span style={{ color: 'var(--color-accent)', fontWeight: 600 }}>
{row.proposed || '—'}
</span>
</div>
))}
</div>
)}
<div
style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}
>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t('metadataEditor.diff.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={onApply}
disabled={changed.length === 0}
>
{t('metadataEditor.diff.apply')}
</Button>
</div>
</div>
); );
} }
@@ -1,13 +1,358 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import {
Badge,
Button,
Callout,
ScrollArea,
SearchField,
SegmentedControl,
Spinner,
} from '@olly/modern-sk';
import {
useGetSourcesQuery,
useLazySearchExternalQuery,
} from '../../api/endpoints/search';
import { useCreateDownloadMutation } from '../../api/endpoints/downloads';
import { Icon } from '../../components/common/Icon';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { formatDuration } from '../../lib/format';
import type { ExternalSearchResult } from '../../api/types';
const ALL = '__all__';
/** Per-result download outcome, keyed by `${source}:${sourceId}`. */
type RowState = 'idle' | 'pending' | 'queued' | 'inLibrary' | 'error';
const rowKey = (r: ExternalSearchResult) => `${r.source}:${r.sourceId}`;
/** `/discover` — A4: search external sources and download into the library. */
export function SearchDownloadPage() { export function SearchDownloadPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const sourcesQuery = useGetSourcesQuery();
const fetchSources = (sourcesQuery.data ?? []).filter(
(s) => s.kind === 'fetch',
);
const hasFetchSource = fetchSources.length > 0;
const [term, setTerm] = useState('');
const [source, setSource] = useState(ALL);
const [search, result] = useLazySearchExternalQuery();
const [createDownload] = useCreateDownloadMutation();
const [rowStates, setRowStates] = useState<Record<string, RowState>>({});
const [queuedAny, setQueuedAny] = useState(false);
const runSearch = (q: string) => {
if (!q) return;
setRowStates({});
void search({ q, source: source === ALL ? undefined : source, limit: 25 });
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
runSearch(term.trim());
};
const download = async (r: ExternalSearchResult) => {
const key = rowKey(r);
setRowStates((s) => ({ ...s, [key]: 'pending' }));
try {
const res = await createDownload({
source: r.source,
sourceId: r.sourceId,
query: term.trim() || undefined,
}).unwrap();
setRowStates((s) => ({
...s,
[key]: res.alreadyInLibrary ? 'inLibrary' : 'queued',
}));
if (!res.alreadyInLibrary) setQueuedAny(true);
} catch {
setRowStates((s) => ({ ...s, [key]: 'error' }));
}
};
const pickerItems = [
{ value: ALL, label: t('discover.allSources') },
...fetchSources.map((s) => ({ value: s.name, label: s.label })),
];
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Window title={t('pages.search')}> <header
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p> style={{
</Window> padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: '0.875rem',
}}
>
<div
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<div>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('discover.title')}
</h2>
<p
style={{
margin: '0.25rem 0 0',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{t('discover.subtitle')}
</p>
</div>
{queuedAny && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => void navigate('/downloads')}
>
<Icon name="arrow-circle-down" /> {t('discover.viewDownloads')}
</Button>
)}
</div>
<form
onSubmit={onSubmit}
style={{ display: 'flex', gap: '0.625rem', alignItems: 'center' }}
>
<div style={{ flex: 1 }}>
<SearchField
icon={<Icon name="magnifying-glass" />}
placeholder={t('discover.searchPlaceholder')}
value={term}
onChange={(e) => setTerm(e.target.value)}
disabled={!hasFetchSource}
/>
</div>
<Button
variant="primary"
type="submit"
disabled={!hasFetchSource || !term.trim()}
>
{t('discover.searchButton')}
</Button>
</form>
{fetchSources.length > 1 && (
<SegmentedControl
value={source}
onValueChange={setSource}
items={pickerItems}
/>
)}
</header>
<ScrollArea style={{ flex: 1 }}>
<div style={{ padding: '1.5rem', maxWidth: 880, margin: '0 auto' }}>
{!sourcesQuery.isLoading && !hasFetchSource && (
<Callout variant="warning">{t('discover.noSources')}</Callout>
)}
{result.isFetching && <LoadingSkeleton rows={6} height={64} />}
{result.isError && !result.isFetching && (
<ErrorState
message={t('discover.searchError')}
onRetry={() => runSearch(term.trim())}
/>
)}
{!result.isFetching &&
!result.isError &&
result.data &&
result.data.results.length === 0 && (
<EmptyState
icon={<Icon name="magnifying-glass" />}
title={t('discover.emptyTitle')}
description={t('discover.emptyDesc')}
/>
)}
{result.isUninitialized && hasFetchSource && (
<EmptyState
icon={<Icon name="magnifying-glass" />}
title={t('discover.startTitle')}
description={t('discover.startDesc')}
/>
)}
{!result.isFetching &&
result.data &&
result.data.results.length > 0 && (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{result.data.results.map((r) => (
<ResultRow
key={rowKey(r)}
result={r}
state={rowStates[rowKey(r)] ?? 'idle'}
onDownload={() => void download(r)}
/>
))}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
function ResultRow({
result,
state,
onDownload,
}: {
result: ExternalSearchResult;
state: RowState;
onDownload: () => void;
}) {
const { t } = useTranslation();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.875rem',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-surface-1)',
}}
>
<Thumb url={result.thumbnailUrl} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.9375rem',
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={result.title}
>
{result.title}
</div>
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{[result.artist, result.album].filter(Boolean).join(' · ') ||
result.source}
</div>
</div>
{result.durationMs != null && (
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
fontVariantNumeric: 'tabular-nums',
}}
>
{formatDuration(result.durationMs)}
</span>
)}
<DownloadControl state={state} onDownload={onDownload} t={t} />
</div>
);
}
function DownloadControl({
state,
onDownload,
t,
}: {
state: RowState;
onDownload: () => void;
t: (k: string) => string;
}) {
if (state === 'inLibrary') {
return (
<Badge variant="outline" dot>
{t('discover.inLibrary')}
</Badge>
);
}
if (state === 'queued') {
return (
<Badge variant="lime" dot>
{t('discover.queued')}
</Badge>
);
}
if (state === 'pending') {
return (
<Button variant="ghost" size="sm" type="button" disabled>
<Spinner size="sm" />
</Button>
);
}
return (
<Button variant="primary" size="sm" type="button" onClick={onDownload}>
<Icon name="arrow-circle-down" />
{state === 'error' ? t('discover.retryDownload') : t('discover.download')}
</Button>
);
}
function Thumb({ url }: { url?: string }) {
return (
<div
style={{
width: 44,
height: 44,
borderRadius: 6,
flexShrink: 0,
overflow: 'hidden',
background: 'var(--color-surface-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-3)',
}}
>
{url ? (
<img
src={url}
alt=""
width={44}
height={44}
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
/>
) : (
<Icon name="vinyl-record" />
)}
</div> </div>
); );
} }
+7 -1
View File
@@ -4,7 +4,13 @@ import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n'; import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
/** Labelled settings row: caption on the left, control on the right. */ /** Labelled settings row: caption on the left, control on the right. */
function SettingRow({ label, children }: { label: string; children: ReactNode }) { function SettingRow({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span <span
+669 -3
View File
@@ -1,13 +1,679 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import { Window, Card, Badge, Callout } from '@olly/modern-sk';
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState';
import { Icon, type IconName } from '../../components/common/Icon';
import { useAppSelector } from '../../hooks/useAppDispatch';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import { useAudioCacheStats } from '../../hooks/useAudioCacheStats';
import {
selectLocalTracks,
selectLocalAlbums,
selectLocalArtists,
} from '../../store/selectors/localLibrary';
import type { AudioCacheStats } from '../../lib/sw';
import {
formatFileSize,
formatCount,
formatLongDuration,
formatDateTime,
} from '../../lib/format';
import type { StorageStats } from '../../api/types';
// modern-sk Badge variants we map metadata-health buckets onto.
const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
{
enriched: 'lime',
manual: 'outline',
pending: 'neutral',
failed: 'ember',
};
export function StoragePage() { export function StoragePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
const offline = useIsOffline();
// Tier-3 audio cache + the locally-cached library metadata = "this device".
const audio = useAudioCacheStats();
const localTracks = useAppSelector(selectLocalTracks);
const localAlbums = useAppSelector(selectLocalAlbums);
const localArtists = useAppSelector(selectLocalArtists);
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
<Window title={t('pages.storage')}> <Window title={t('pages.storage')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p> <p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
{t('storage.subtitle')}
</p>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}
>
{/* ── On this device (local + cached) ───────────────────────── */}
<div>
<SectionTitle icon="hard-drives">
{t('storage.device')}
</SectionTitle>
<div style={{ marginTop: '0.75rem' }}>
<LocalStoragePanel
audio={audio}
trackCount={localTracks.length}
albumCount={localAlbums.length}
artistCount={localArtists.length}
/>
</div>
</div>
{/* ── On the server (remote) ────────────────────────────────── */}
<div>
<SectionTitle icon="cloud">{t('storage.server')}</SectionTitle>
<div style={{ marginTop: '0.75rem' }}>
{isLoading && <LoadingSkeleton rows={6} height={72} />}
{isError && offline && (
<Callout variant="info">
{t('storage.serverUnreachable')}
</Callout>
)}
{isError && !offline && (
<ErrorState
message={t('common.error')}
onRetry={() => refetch()}
/>
)}
{data && data.totalTracks === 0 && (
<EmptyState
icon={<Icon name="hard-drives" />}
title={t('storage.emptyTitle')}
description={t('storage.emptyDesc')}
/>
)}
{data && data.totalTracks > 0 && (
<StorageDashboard stats={data} />
)}
</div>
</div>
</div>
</Window> </Window>
</div> </div>
); );
} }
/** "On this device": the SW audio cache (downloaded audio) + the offline
* library metadata we can browse without the server. */
function LocalStoragePanel({
audio,
trackCount,
albumCount,
artistCount,
}: {
audio: AudioCacheStats | null;
trackCount: number;
albumCount: number;
artistCount: number;
}) {
const { t } = useTranslation();
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: '1rem',
}}
>
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="arrow-circle-down">
{t('storage.audioCache')}
</SectionTitle>
{audio ? (
<>
<div
style={{
height: 12,
borderRadius: 999,
overflow: 'hidden',
background: 'var(--color-surface-3)',
marginTop: '0.75rem',
}}
>
<div
style={{
width: `${audio.maxBytes > 0 ? Math.min((audio.bytes / audio.maxBytes) * 100, 100) : 0}%`,
height: '100%',
background: 'var(--color-accent)',
transition: 'width 0.5s ease',
}}
/>
</div>
<div
style={{
marginTop: '0.6rem',
fontSize: '0.85rem',
color: 'var(--color-text-2)',
}}
>
{t('storage.audioCacheUsage', {
used: formatFileSize(audio.bytes),
max: formatFileSize(audio.maxBytes),
})}
</div>
<div
style={{
marginTop: '0.25rem',
fontSize: '0.8rem',
color: 'var(--color-text-3)',
}}
>
{t('storage.cachedTracks', { n: audio.count })}
</div>
</>
) : (
<p
style={{
margin: '0.75rem 0 0',
fontSize: '0.85rem',
color: 'var(--color-text-3)',
}}
>
{t('storage.audioCacheUnavailable')}
</p>
)}
</Card>
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="vinyl-record">
{t('storage.offlineLibrary')}
</SectionTitle>
<p
style={{
margin: '0.75rem 0 0',
fontSize: '1.5rem',
fontWeight: 600,
color: 'var(--color-text-1)',
lineHeight: 1.1,
}}
>
{formatCount(trackCount)}
</p>
<div
style={{
marginTop: '0.35rem',
fontSize: '0.8rem',
color: 'var(--color-text-2)',
}}
>
{t('storage.offlineLibraryMeta', {
tracks: formatCount(trackCount),
albums: formatCount(albumCount),
artists: formatCount(artistCount),
})}
</div>
</Card>
</div>
);
}
function StorageDashboard({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{stats.disk && (
<DiskGauge disk={stats.disk} libraryBytes={stats.totalSize} />
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '0.75rem',
}}
>
<StatTile
icon="vinyl-record"
label={t('storage.tracks')}
value={formatCount(stats.totalTracks)}
/>
<StatTile
icon="vinyl-record"
label={t('storage.artists')}
value={formatCount(stats.totalArtists)}
/>
<StatTile
icon="vinyl-record"
label={t('storage.albums')}
value={formatCount(stats.totalAlbums)}
/>
<StatTile
icon="play"
label={t('storage.playtime')}
value={formatLongDuration(stats.totalDurationSeconds)}
/>
<StatTile
icon="hard-drives"
label={t('storage.footprint')}
value={formatFileSize(stats.totalSize)}
/>
<StatTile
icon="hard-drives"
label={t('storage.avgTrackSize')}
value={formatFileSize(avgSize)}
/>
</div>
{stats.byFormat.length > 0 && <FormatBars stats={stats} />}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: '1rem',
}}
>
<MetadataHealth stats={stats} />
<Sources stats={stats} />
</div>
{stats.topGenres.length > 0 && <TopGenres stats={stats} />}
<FunFacts stats={stats} avgSize={avgSize} />
</div>
);
}
function DiskGauge({
disk,
libraryBytes,
}: {
disk: NonNullable<StorageStats['disk']>;
libraryBytes: number;
}) {
const { t } = useTranslation();
const pct = (n: number) => (disk.total > 0 ? (n / disk.total) * 100 : 0);
// The library is a slice of "used"; the rest of used is everything else on
// the volume. Clamp so a slightly-stale library total never overflows.
const libShare = Math.min(libraryBytes, disk.used);
const otherUsed = Math.max(disk.used - libShare, 0);
const libPercentOfDisk = pct(libraryBytes).toFixed(1);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="hard-drives">{t('storage.disk')}</SectionTitle>
<div
style={{
display: 'flex',
height: 16,
borderRadius: 999,
overflow: 'hidden',
background: 'var(--color-surface-3)',
marginTop: '0.75rem',
}}
>
<div
style={{
width: `${pct(libShare)}%`,
background: 'var(--color-accent)',
transition: 'width 0.5s ease',
}}
/>
<div
style={{
width: `${pct(otherUsed)}%`,
background: 'var(--color-text-3)',
opacity: 0.5,
}}
/>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.6rem',
fontSize: '0.85rem',
color: 'var(--color-text-2)',
}}
>
<span>
<Dot color="var(--color-accent)" /> {formatFileSize(libraryBytes)}{' '}
{t('storage.footprint').toLowerCase()}
</span>
<span>
{t('storage.diskUsage', {
used: formatFileSize(disk.used),
total: formatFileSize(disk.total),
})}
</span>
<span>
{t('storage.diskFree', { free: formatFileSize(disk.free) })}
</span>
</div>
<p
style={{
margin: '0.5rem 0 0',
fontSize: '0.8rem',
color: 'var(--color-text-3)',
}}
>
{t('storage.diskLibraryShare', { percent: libPercentOfDisk })}
</p>
</Card>
);
}
function FormatBars({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const max = Math.max(...stats.byFormat.map((f) => f.totalSize), 1);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="vinyl-record">{t('storage.formats')}</SectionTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.7rem',
marginTop: '0.75rem',
}}
>
{stats.byFormat.map((f) => (
<div key={f.fileFormat}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.85rem',
marginBottom: '0.25rem',
}}
>
<span
style={{
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: 'var(--color-text-1)',
}}
>
{f.fileFormat}
</span>
<span style={{ color: 'var(--color-text-2)' }}>
{formatCount(f.trackCount)} · {formatFileSize(f.totalSize)}
</span>
</div>
<div
style={{
height: 8,
borderRadius: 999,
background: 'var(--color-surface-3)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${(f.totalSize / max) * 100}%`,
height: '100%',
background: 'var(--color-accent)',
transition: 'width 0.5s ease',
}}
/>
</div>
</div>
))}
</div>
</Card>
);
}
function MetadataHealth({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const entries = Object.entries(stats.byMetadataStatus).filter(
([, n]) => n > 0,
);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="check-circle">
{t('storage.metadataHealth')}
</SectionTitle>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.75rem',
}}
>
{entries.map(([status, count]) => (
<Badge key={status} variant={STATUS_VARIANT[status] ?? 'neutral'}>
{t(`storage.status.${status}`, { defaultValue: status })} ·{' '}
{formatCount(count)}
</Badge>
))}
</div>
</Card>
);
}
function Sources({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const entries = Object.entries(stats.bySource).sort((a, b) => b[1] - a[1]);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="arrow-circle-down">
{t('storage.sources')}
</SectionTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
marginTop: '0.75rem',
}}
>
{entries.map(([source, count]) => (
<div
key={source}
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.9rem',
textTransform: 'capitalize',
}}
>
<span style={{ color: 'var(--color-text-1)' }}>{source}</span>
<span style={{ color: 'var(--color-text-2)' }}>
{formatCount(count)}
</span>
</div>
))}
</div>
</Card>
);
}
function TopGenres({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const max = Math.max(...stats.topGenres.map((g) => g.trackCount), 1);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="vinyl-record">{t('storage.topGenres')}</SectionTitle>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.75rem',
}}
>
{stats.topGenres.map((g) => {
// Scale chip emphasis by popularity for a tag-cloud feel.
const weight = 0.55 + (g.trackCount / max) * 0.45;
return (
<span
key={g.genre}
style={{
padding: '0.3rem 0.7rem',
borderRadius: 999,
border: '1px solid var(--color-border)',
background: `color-mix(in srgb, var(--color-accent) ${Math.round(
weight * 18,
)}%, transparent)`,
fontSize: '0.85rem',
color: 'var(--color-text-1)',
}}
>
{g.genre}{' '}
<span style={{ color: 'var(--color-text-3)' }}>
{formatCount(g.trackCount)}
</span>
</span>
);
})}
</div>
</Card>
);
}
function FunFacts({
stats,
avgSize,
}: {
stats: StorageStats;
avgSize: number;
}) {
const { t } = useTranslation();
const facts: string[] = [];
if (stats.totalDurationSeconds > 0)
facts.push(
t('storage.factPlaytime', {
duration: formatLongDuration(stats.totalDurationSeconds),
}),
);
facts.push(
t('storage.factFootprint', {
size: formatFileSize(stats.totalSize),
tracks: formatCount(stats.totalTracks),
}),
);
if (stats.topGenres[0])
facts.push(
t('storage.factGenre', {
genre: stats.topGenres[0].genre,
count: stats.topGenres[0].trackCount,
}),
);
facts.push(t('storage.factAvg', { size: formatFileSize(avgSize) }));
const since = formatDateTime(stats.earliestAdded);
if (since) facts.push(t('storage.factSince', { date: since }));
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="info">{t('storage.funFacts')}</SectionTitle>
<ul
style={{
margin: '0.75rem 0 0',
paddingLeft: '1.1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
color: 'var(--color-text-2)',
fontSize: '0.9rem',
}}
>
{facts.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</Card>
);
}
// -- small shared bits --------------------------------------------------------
function StatTile({
icon,
label,
value,
}: {
icon: IconName;
label: string;
value: string;
}) {
return (
<Card
style={{
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.35rem',
}}
>
<span
style={{
color: 'var(--color-accent)',
fontSize: '1.1rem',
opacity: 0.9,
}}
>
<Icon name={icon} />
</span>
<span
style={{
fontSize: '1.5rem',
fontWeight: 600,
color: 'var(--color-text-1)',
lineHeight: 1.1,
}}
>
{value}
</span>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-2)' }}>
{label}
</span>
</Card>
);
}
function SectionTitle({
icon,
children,
}: {
icon: IconName;
children: ReactNode;
}) {
return (
<h3
style={{
margin: 0,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.95rem',
fontWeight: 600,
color: 'var(--color-text-1)',
}}
>
<span style={{ color: 'var(--color-accent)' }}>
<Icon name={icon} />
</span>
{children}
</h3>
);
}
function Dot({ color }: { color: string }) {
return (
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 999,
background: color,
marginRight: 2,
}}
/>
);
}
+121 -11
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk'; import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
@@ -6,6 +6,23 @@ import {
buildUploadFormData, buildUploadFormData,
useUploadTrackMutation, useUploadTrackMutation,
} from '../../api/endpoints/upload'; } from '../../api/endpoints/upload';
import {
useGetTrackQuery,
useGetTracksQuery,
} from '../../api/endpoints/library';
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
* it survives a page refresh — unlike the transient client-side queue above. */
const RECENT_UPLOADS = {
source: 'upload',
sortBy: 'dateAdded',
sortOrder: 'desc',
pageSize: 20,
} as const;
/** Pure client-side state — this is a transient upload queue, never server data. */ /** Pure client-side state — this is a transient upload queue, never server data. */
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error'; type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
@@ -23,7 +40,10 @@ const MAX_CONCURRENCY = 3;
function extractError(err: unknown): string { function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null) { if (typeof err === 'object' && err !== null) {
const e = err as { data?: { message?: string; detail?: string }; error?: string }; const e = err as {
data?: { message?: string; detail?: string };
error?: string;
};
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed'; return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
} }
return 'Upload failed'; return 'Upload failed';
@@ -38,18 +58,27 @@ export function UploadPage() {
const [items, setItems] = useState<QueueItem[]>([]); const [items, setItems] = useState<QueueItem[]>([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
// Persisted view of past uploads. Auto-refreshes after each upload because the
// upload mutation invalidates the `Track` tag this query provides.
const recentQuery = useGetTracksQuery(RECENT_UPLOADS);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const idCounter = useRef(0); const idCounter = useRef(0);
const activeCount = useRef(0); const activeCount = useRef(0);
const pending = useRef<QueueItem[]>([]); const pending = useRef<QueueItem[]>([]);
const patchItem = (id: string, patch: Partial<QueueItem>) => const patchItem = (id: string, patch: Partial<QueueItem>) =>
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it))); setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, ...patch } : it)),
);
// Ref-based concurrency pump: refs (not state) so it is safe to call from // Ref-based concurrency pump: refs (not state) so it is safe to call from
// async callbacks without stale closures over the queue. // async callbacks without stale closures over the queue.
const pump = () => { const pump = () => {
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) { while (
activeCount.current < MAX_CONCURRENCY &&
pending.current.length > 0
) {
const item = pending.current.shift()!; const item = pending.current.shift()!;
activeCount.current += 1; activeCount.current += 1;
patchItem(item.id, { status: 'uploading', error: undefined }); patchItem(item.id, { status: 'uploading', error: undefined });
@@ -161,7 +190,9 @@ export function UploadPage() {
> >
<div style={{ fontSize: '2rem' }}></div> <div style={{ fontSize: '2rem' }}></div>
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div> <div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}> <div
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
>
{t('upload.dropzone.hint')} {t('upload.dropzone.hint')}
</div> </div>
<Button variant="primary" size="sm" type="button"> <Button variant="primary" size="sm" type="button">
@@ -181,7 +212,11 @@ export function UploadPage() {
{items.length > 0 && ( {items.length > 0 && (
<div <div
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }} style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
> >
<div <div
style={{ style={{
@@ -226,6 +261,32 @@ export function UploadPage() {
))} ))}
</div> </div>
)} )}
<section
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
{t('upload.recent.title')}
</span>
{recentQuery.isLoading && <LoadingSkeleton rows={4} />}
{recentQuery.isError && (
<ErrorState onRetry={() => recentQuery.refetch()} />
)}
{recentQuery.data && recentQuery.data.items.length === 0 && (
<p
style={{
margin: 0,
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{t('upload.recent.empty')}
</p>
)}
{recentQuery.data?.items.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} showAlbum />
))}
</section>
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
@@ -273,11 +334,7 @@ function UploadRow({
{item.error} {item.error}
</div> </div>
)} )}
{done && ( {done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{t('upload.unknownArtist')}
</div>
)}
</div> </div>
<StatusBadge status={item.status} /> <StatusBadge status={item.status} />
@@ -301,6 +358,59 @@ function UploadRow({
); );
} }
/**
* Polls a just-uploaded track until enrichment settles, then shows the outcome.
* Metadata enrichment runs asynchronously in a worker after the upload response
* returns, so without polling the row would never reflect the resolved title/
* artist or a failure reason. Polling stops (interval → 0) once the status
* leaves `pending`.
*/
function EnrichmentStatus({ trackId }: { trackId: string }) {
const { t } = useTranslation();
const [pollMs, setPollMs] = useState(2500);
const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs });
useEffect(() => {
if (data && data.metadataStatus !== 'pending') setPollMs(0);
}, [data]);
const status = data?.metadataStatus ?? 'pending';
const resolved =
data && data.metadataStatus === 'enriched'
? `${data.artistName} · ${data.title}`
: t(`metadata.statusHint.${status}`);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<MetadataStatusBadge
status={status}
error={data?.metadataError}
hideWhenEnriched={false}
/>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{status === 'failed' && data?.metadataError
? data.metadataError
: resolved}
</span>
</div>
);
}
function StatusBadge({ status }: { status: ItemStatus }) { function StatusBadge({ status }: { status: ItemStatus }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (status === 'uploading') { if (status === 'uploading') {
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { getAudioCacheStats, type AudioCacheStats } from '../lib/sw';
/**
* Loads the service-worker audio offline cache stats (Tier 3 — the audio
* actually stored on *this device*). Returns `null` until resolved, or when no
* controlling service worker is present (insecure origin, first load, …).
* `bump` forces a re-read after the cache is mutated (e.g. cleared).
*/
export function useAudioCacheStats(bump = 0): AudioCacheStats | null {
const [stats, setStats] = useState<AudioCacheStats | null>(null);
useEffect(() => {
let cancelled = false;
void getAudioCacheStats().then((s) => {
if (!cancelled) setStats(s);
});
return () => {
cancelled = true;
};
}, [bump]);
return stats;
}
+10 -1
View File
@@ -27,6 +27,10 @@ export function useAudioPlayer() {
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken); const accessToken = useAppSelector((s) => s.auth.accessToken);
const isSetup = useRef(false); const isSetup = useRef(false);
// `ended` is registered once below; read the latest loop flag through a ref
// so the listener doesn't need to be re-bound on every queue change.
const loopRef = useRef(queue.loop);
loopRef.current = queue.loop;
useEffect(() => { useEffect(() => {
if (isSetup.current) return; if (isSetup.current) return;
@@ -41,7 +45,12 @@ export function useAudioPlayer() {
dispatch(setDuration(audio.duration || 0)); dispatch(setDuration(audio.duration || 0));
}); });
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
dispatch(nextTrack()); if (loopRef.current) {
audio.currentTime = 0;
void audio.play();
} else {
dispatch(nextTrack());
}
}); });
audio.addEventListener('pause', () => { audio.addEventListener('pause', () => {
dispatch(pause()); dispatch(pause());
+34 -4
View File
@@ -1,9 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getApiBaseUrl } from '../config/runtime-config'; import { getApiBaseUrl } from '../config/runtime-config';
import { useAppDispatch, useAppSelector } from './useAppDispatch';
import {
setConnectionStatus,
type ConnectionStatus,
} from '../store/slices/connection';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error'; export type { ConnectionStatus };
export function useConnectionStatus() { /** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) {
const url = baseUrl ?? getApiBaseUrl();
const [status, setStatus] = useState<ConnectionStatus>('connecting'); const [status, setStatus] = useState<ConnectionStatus>('connecting');
useEffect(() => { useEffect(() => {
@@ -13,7 +20,7 @@ export function useConnectionStatus() {
if (cancelled) return; if (cancelled) return;
setStatus('connecting'); setStatus('connecting');
try { try {
const res = await fetch(`${getApiBaseUrl()}/health`, { const res = await fetch(`${url}/health`, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
if (!cancelled) setStatus(res.ok ? 'connected' : 'error'); if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
@@ -30,7 +37,30 @@ export function useConnectionStatus() {
cancelled = true; cancelled = true;
clearInterval(interval); clearInterval(interval);
}; };
}, []); }, [url]);
return status; return status;
} }
/**
* Like `useConnectionStatus`, but also mirrors the result into the
* `connection` slice so other components can read the active instance's
* reachability via `useIsOffline` without running their own poller.
* Mount once (in `Sidebar`, which lives for the app's whole lifetime).
*/
export function useConnectionStatusSync(): ConnectionStatus {
const status = useConnectionStatus();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setConnectionStatus(status));
}, [status, dispatch]);
return status;
}
/** Whether the active backend instance is currently unreachable. */
export function useIsOffline(): boolean {
const status = useAppSelector((s) => s.connection.status);
return status === 'disconnected' || status === 'error';
}
+41
View File
@@ -0,0 +1,41 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useGetTrackQuery } from '../api/endpoints/library';
import type { QueueEntry } from '../store/slices/queue';
export interface ResolvedQueueEntry {
trackId: string;
title: string;
artistName: string;
albumTitle: string;
durationMs: number;
hasCover: boolean;
albumArtUrl?: string;
format?: string;
}
/**
* Merge a queue entry's play-time snapshot with the live `Track` cache.
*
* The queue slice stores denormalized display fields (title/artist/…) captured
* when a track was queued, so they go stale after metadata enrichment updates
* the track. This reads through to the RTKQ `Track` cache — invalidated by the
* same tags that refresh the library — and prefers its fresh values, falling
* back to the snapshot for instant render and offline. Returns undefined when
* there is no current entry.
*/
export function useResolvedQueueEntry(
entry: QueueEntry | undefined,
): ResolvedQueueEntry | undefined {
const { data } = useGetTrackQuery(entry?.trackId ?? skipToken);
if (!entry) return undefined;
return {
trackId: entry.trackId,
title: data?.title ?? entry.title,
artistName: data?.artistName ?? entry.artistName,
albumTitle: data?.albumTitle ?? entry.albumTitle,
durationMs: data?.durationMs ?? entry.durationMs,
hasCover: data?.hasCover ?? false,
albumArtUrl: data?.albumArtUrl ?? entry.albumArtUrl,
format: data?.format,
};
}
+256 -17
View File
@@ -25,22 +25,46 @@ const en = {
signOut: 'Sign out', signOut: 'Sign out',
}, },
connect: { connect: {
savedInstances: 'Saved instances', domains: {
active: 'active', title: 'Saved instances',
use: 'Use', addPlaceholder: 'https://your-server.example.com',
forgetTitle: 'Forget this instance', addButton: 'Add instance',
form: { selected: 'Selected',
title: 'Connect to a backend', use: 'Use',
serverUrl: 'Server URL', forgetTitle: 'Remove this instance',
},
removeDialog: {
title: 'Remove cached data?',
description:
'This removes "{{name}}" from your saved instances and clears its cached data on this device.',
cancel: 'Cancel',
logout: 'Just log out',
removeAndLogout: 'Remove data & log out',
},
login: {
title: 'Log in to {{name}}',
registerTitle: 'Sign up for {{name}}',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
submit: 'Connect', passwordHint: 'At least 8 characters.',
submitting: 'Connecting…', submit: 'Log in',
submitting: 'Logging in…',
registerSubmit: 'Sign up',
registering: 'Signing up…',
noAccount: "Don't have an account?",
registerLink: 'Sign up',
haveAccount: 'Already have an account?',
signInLink: 'Log in',
}, },
errors: { errors: {
unreachable: "Can't reach this server. Check the URL and that it's online.", unreachable:
"Can't reach this server. Check the URL and that it's online.",
badCredentials: 'Incorrect username or password.', badCredentials: 'Incorrect username or password.',
generic: 'Sign-in failed. Please try again.', generic: 'Sign-in failed. Please try again.',
usernameTaken: 'That username is already taken.',
passwordTooShort: 'Password must be at least 8 characters.',
registrationDisabled: 'Registration is disabled on this server.',
registerFailed: 'Could not create the account. Please try again.',
}, },
}, },
library: { library: {
@@ -73,6 +97,13 @@ const en = {
artistRow: { artistRow: {
meta: '{{albumCount}} albums · {{trackCount}} tracks', meta: '{{albumCount}} albums · {{trackCount}} tracks',
}, },
offline: {
banner:
"You're offline — showing the library available locally. It may be incomplete and is read-only until the server is back.",
emptyTitle: 'Nothing available offline',
emptyDesc:
'No library data is cached on this device yet. Connect to the server once to browse offline.',
},
}, },
album: { album: {
type: 'Album', type: 'Album',
@@ -83,6 +114,28 @@ const en = {
title: 'No tracks', title: 'No tracks',
description: 'This album has no tracks.', description: 'This album has no tracks.',
}, },
offline: {
title: 'Album not available offline',
description: "You're offline and this album isn't cached on this device.",
},
},
artist: {
type: 'Artist',
play: '▶ Play all',
error: 'Failed to load artist',
meta: '{{albumCount}} albums · {{trackCount}} tracks',
albums: 'Albums',
tracks: 'Tracks',
noAlbums: 'No albums yet.',
empty: {
title: 'No tracks',
description: 'This artist has no tracks.',
},
offline: {
title: 'Artist not available offline',
description:
"You're offline and this artist isn't cached on this device.",
},
}, },
playlist: { playlist: {
type: 'Playlist', type: 'Playlist',
@@ -96,27 +149,25 @@ const en = {
}, },
player: { player: {
nothingPlaying: 'Nothing playing', nothingPlaying: 'Nothing playing',
shuffle: 'Shuffle',
previous: 'Previous', previous: 'Previous',
next: 'Next', next: 'Next',
pause: 'Pause', pause: 'Pause',
play: 'Play', play: 'Play',
repeat: 'Repeat: {{mode}}', streaming: 'Streaming',
streaming: 'Streaming · 320 kbps', local: 'Local',
local: 'Local · FLAC',
queue: 'Play queue', queue: 'Play queue',
mute: 'Mute', mute: 'Mute',
unmute: 'Unmute', unmute: 'Unmute',
}, },
queue: { queue: {
title: 'Play queue', title: 'Play queue',
shuffle: 'Shuffle queue',
loop: 'Repeat current track',
clear: 'Clear queue', clear: 'Clear queue',
close: 'Close', close: 'Close',
from: 'From {{source}}', from: 'From {{source}}',
radio: 'Radio · {{source}}', radio: 'Radio · {{source}}',
nowPlaying: 'Now playing',
nextUp: 'Next up', nextUp: 'Next up',
nothingNext: 'Nothing queued next',
empty: 'Queue is empty', empty: 'Queue is empty',
radioActive: 'Radio active', radioActive: 'Radio active',
mixing: '∞ mixing', mixing: '∞ mixing',
@@ -125,6 +176,13 @@ const en = {
loadingMore: 'Loading more from radio…', loadingMore: 'Loading more from radio…',
doubleClickPlay: 'Double-click to play', doubleClickPlay: 'Double-click to play',
removeFromQueue: 'Remove from queue', removeFromQueue: 'Remove from queue',
menu: {
options: 'Track options',
playNow: 'Play now',
moveNext: 'Move next',
info: 'Track info',
remove: 'Remove from queue',
},
}, },
track: { track: {
menu: { menu: {
@@ -132,17 +190,103 @@ const en = {
playNow: 'Play now', playNow: 'Play now',
playNext: 'Play next', playNext: 'Play next',
addToQueue: 'Add to queue', addToQueue: 'Add to queue',
info: 'Track info',
addToPlaylist: 'Add to playlist…', addToPlaylist: 'Add to playlist…',
editMetadata: 'Edit metadata', editMetadata: 'Edit metadata',
download: 'Download', download: 'Download',
delete: 'Delete', delete: 'Delete',
}, },
}, },
trackInfo: {
title: 'Track info',
open: 'View track info',
close: 'Close',
notFound: 'Track not found',
play: 'Play',
addToQueue: 'Queue',
editMetadata: 'Edit metadata',
liked: 'Liked',
trackOf: 'No. {{n}} of {{total}}',
kbps: '{{n}} kbps',
sections: {
status: 'Status',
general: 'General',
file: 'File',
identifiers: 'Identifiers',
},
fields: {
artist: 'Artist',
album: 'Album',
trackNumber: 'Track',
disc: 'Disc',
year: 'Year',
genre: 'Genre',
duration: 'Duration',
format: 'Format',
bitrate: 'Bitrate',
size: 'Size',
source: 'Source',
added: 'Added',
enriched: 'Enriched',
trackId: 'Track ID',
albumId: 'Album ID',
artistId: 'Artist ID',
},
},
common: { common: {
error: 'Something went wrong', error: 'Something went wrong',
retry: 'Retry', retry: 'Retry',
comingSoon: 'Coming soon', comingSoon: 'Coming soon',
back: 'Back', back: 'Back',
offlineBanner:
"You're offline — showing locally available data, read-only.",
},
storage: {
subtitle: 'Everything this instance has tucked away',
device: 'On this device',
server: 'On the server',
audioCache: 'Cached audio',
audioCacheUsage: '{{used}} of {{max}} used',
cachedTracks: '{{n}} tracks cached for offline',
audioCacheUnavailable:
'Offline audio cache unavailable (service worker not active).',
offlineLibrary: 'Offline library',
offlineLibraryMeta:
'{{tracks}} tracks · {{albums}} albums · {{artists}} artists browsable offline',
serverUnreachable: 'Server unreachable — showing this device only.',
emptyTitle: 'Nothing stored yet',
emptyDesc:
'Download or upload some music and your library stats will appear here.',
disk: 'Disk',
diskUsage: '{{used}} of {{total}} used',
diskFree: '{{free}} free',
diskLibraryShare: 'This library is {{percent}}% of the whole disk',
diskUnknown: 'Object storage — no fixed disk to report',
footprint: 'Library footprint',
tracks: 'Tracks',
artists: 'Artists',
albums: 'Albums',
playtime: 'Total playtime',
avgTrackSize: 'Avg. track size',
largestTrack: 'Largest track',
formats: 'Formats',
sources: 'Where it came from',
metadataHealth: 'Metadata health',
topGenres: 'Top genres',
noGenres: 'No genres tagged yet',
funFacts: 'Fun facts',
factPlaytime:
'Hit play and walk away — this library runs for {{duration}} non-stop.',
factFootprint: '{{size}} of music across {{tracks}} tracks.',
factGenre: 'Your most-tagged genre is {{genre}} ({{count}} tracks).',
factAvg: 'The average track weighs in at {{size}}.',
factSince: 'Collecting since {{date}}.',
status: {
enriched: 'Enriched',
manual: 'Manual',
pending: 'Pending',
failed: 'Failed',
},
}, },
pages: { pages: {
admin: 'Admin', admin: 'Admin',
@@ -195,6 +339,10 @@ const en = {
clearCompleted: 'Clear completed', clearCompleted: 'Clear completed',
retry: 'Retry', retry: 'Retry',
editMetadata: 'Edit metadata', editMetadata: 'Edit metadata',
recent: {
title: 'Recently uploaded',
empty: 'Nothing uploaded yet.',
},
metadataPending: metadataPending:
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.', 'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
unknownArtist: 'Unknown Artist · metadata pending', unknownArtist: 'Unknown Artist · metadata pending',
@@ -206,11 +354,102 @@ const en = {
error: 'Failed', error: 'Failed',
}, },
}, },
discover: {
title: 'Search & download',
subtitle: 'Find music on connected sources and add it to your library.',
searchPlaceholder: 'Search for a song, artist, or album…',
searchButton: 'Search',
allSources: 'All sources',
noSources:
'No download sources are configured. Enable a source (e.g. YouTube Music) on the server to search and download.',
startTitle: 'Search to get started',
startDesc: 'Results from your connected sources will appear here.',
emptyTitle: 'No results',
emptyDesc: 'Try a different search term or another source.',
searchError: "Couldn't search right now. Try again.",
download: 'Download',
retryDownload: 'Retry',
queued: 'Queued',
inLibrary: 'In library',
viewDownloads: 'View downloads',
},
downloads: {
title: 'Downloads',
subtitle: 'Track downloads in progress, completed, and failed.',
activeCount: '{{count}} active',
sectionActive: 'In progress',
sectionHistory: 'History',
emptyTitle: 'No downloads yet',
emptyDesc: 'Find music in Search & download to queue it here.',
loadError: "Couldn't load downloads.",
failedBanner:
'{{count}} download(s) failed. Retry them, or check the source on the server.',
attempt: 'Attempt {{count}}',
open: 'Open',
retry: 'Retry',
cancel: 'Cancel',
status: {
queued: 'Queued',
downloading: 'Downloading',
enriching: 'Enriching',
done: 'Done',
failed: 'Failed',
},
},
metadata: {
status: {
pending: 'Enriching…',
enriched: 'Enriched',
failed: 'No match',
manual: 'Manual',
},
statusHint: {
pending: 'Identifying metadata…',
enriched: 'Metadata identified',
failed: 'Metadata could not be identified',
manual: 'Edited manually — not auto-updated',
},
},
metadataEditor: {
error: 'Failed to load track',
saved: 'Metadata saved.',
saveError: 'Failed to save metadata.',
save: 'Save',
fields: {
title: 'Title',
artist: 'Artist',
album: 'Album',
year: 'Year',
genre: 'Genre',
trackNumber: 'Track number',
},
autoEnrich: {
title: 'AcoustID lookup',
hint: 'Identify this track by audio fingerprint.',
findMatches: 'Find matches',
reEnrich: 'Re-run enrichment',
enqueued: 'Enrichment queued — refresh in a moment.',
error: 'Could not look up matches.',
noMatches: 'No matches found.',
},
matches: {
use: 'Use',
unknownTitle: 'Unknown title',
},
diff: {
title: 'Apply this match?',
noChanges: 'No changes from current values.',
cancel: 'Cancel',
apply: 'Apply',
},
},
} as const; } as const;
export default en; export default en;
type DeepString<T> = { type DeepString<T> = {
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string; [K in keyof T]: T[K] extends Record<string, unknown>
? DeepString<T[K]>
: string;
}; };
export type Translations = DeepString<typeof en>; export type Translations = DeepString<typeof en>;
+251 -15
View File
@@ -27,23 +27,46 @@ const ru: Translations = {
signOut: 'Выйти', signOut: 'Выйти',
}, },
connect: { connect: {
savedInstances: 'Сохранённые серверы', domains: {
active: 'активный', title: 'Сохранённые серверы',
use: 'Выбрать', addPlaceholder: 'https://your-server.example.com',
forgetTitle: 'Забыть этот сервер', addButton: 'Добавить сервер',
form: { selected: 'Выбран',
title: 'Подключиться к серверу', use: 'Выбрать',
serverUrl: 'URL сервера', forgetTitle: 'Удалить этот сервер',
},
removeDialog: {
title: 'Удалить локальные данные?',
description:
'Сервер «{{name}}» будет удалён из сохранённых, а его данные на этом устройстве — очищены.',
cancel: 'Отмена',
logout: 'Просто выйти',
removeAndLogout: 'Удалить данные и выйти',
},
login: {
title: 'Вход в {{name}}',
registerTitle: 'Регистрация на {{name}}',
username: 'Имя пользователя', username: 'Имя пользователя',
password: 'Пароль', password: 'Пароль',
submit: 'Подключиться', passwordHint: 'Не менее 8 символов.',
submitting: 'Подключение…', submit: 'Войти',
submitting: 'Вход…',
registerSubmit: 'Зарегистрироваться',
registering: 'Регистрация…',
noAccount: 'Нет аккаунта?',
registerLink: 'Зарегистрироваться',
haveAccount: 'Уже есть аккаунт?',
signInLink: 'Войти',
}, },
errors: { errors: {
unreachable: unreachable:
'Не удаётся подключиться к серверу. Проверьте URL и доступность.', 'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
badCredentials: 'Неверное имя пользователя или пароль.', badCredentials: 'Неверное имя пользователя или пароль.',
generic: 'Не удалось войти. Попробуйте ещё раз.', generic: 'Не удалось войти. Попробуйте ещё раз.',
usernameTaken: 'Это имя пользователя уже занято.',
passwordTooShort: 'Пароль должен содержать не менее 8 символов.',
registrationDisabled: 'Регистрация на этом сервере отключена.',
registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.',
}, },
}, },
library: { library: {
@@ -76,6 +99,13 @@ const ru: Translations = {
artistRow: { artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков', meta: '{{albumCount}} альб. · {{trackCount}} треков',
}, },
offline: {
banner:
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
emptyTitle: 'Офлайн ничего нет',
emptyDesc:
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
},
}, },
album: { album: {
type: 'Альбом', type: 'Альбом',
@@ -86,6 +116,27 @@ const ru: Translations = {
title: 'Нет треков', title: 'Нет треков',
description: 'В этом альбоме нет треков.', description: 'В этом альбоме нет треков.',
}, },
offline: {
title: 'Альбом недоступен офлайн',
description: 'Нет связи, а этот альбом не сохранён на устройстве.',
},
},
artist: {
type: 'Исполнитель',
play: '▶ Слушать всё',
error: 'Не удалось загрузить исполнителя',
meta: '{{albumCount}} альбомов · {{trackCount}} треков',
albums: 'Альбомы',
tracks: 'Треки',
noAlbums: 'Пока нет альбомов.',
empty: {
title: 'Нет треков',
description: 'У этого исполнителя нет треков.',
},
offline: {
title: 'Исполнитель недоступен офлайн',
description: 'Нет связи, а этот исполнитель не сохранён на устройстве.',
},
}, },
playlist: { playlist: {
type: 'Плейлист', type: 'Плейлист',
@@ -99,27 +150,25 @@ const ru: Translations = {
}, },
player: { player: {
nothingPlaying: 'Ничего не играет', nothingPlaying: 'Ничего не играет',
shuffle: 'Перемешать',
previous: 'Назад', previous: 'Назад',
next: 'Вперёд', next: 'Вперёд',
pause: 'Пауза', pause: 'Пауза',
play: 'Воспроизвести', play: 'Воспроизвести',
repeat: 'Повтор: {{mode}}', streaming: 'Стриминг',
streaming: 'Стриминг · 320 kbps', local: 'Локально',
local: 'Локально · FLAC',
queue: 'Очередь', queue: 'Очередь',
mute: 'Выключить звук', mute: 'Выключить звук',
unmute: 'Включить звук', unmute: 'Включить звук',
}, },
queue: { queue: {
title: 'Очередь воспроизведения', title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь',
loop: 'Повторять текущий трек',
clear: 'Очистить очередь', clear: 'Очистить очередь',
close: 'Закрыть', close: 'Закрыть',
from: 'Из: {{source}}', from: 'Из: {{source}}',
radio: 'Радио · {{source}}', radio: 'Радио · {{source}}',
nowPlaying: 'Сейчас играет',
nextUp: 'Далее', nextUp: 'Далее',
nothingNext: 'Очередь пуста',
empty: 'Очередь пуста', empty: 'Очередь пуста',
radioActive: 'Радио активно', radioActive: 'Радио активно',
mixing: '∞ микс', mixing: '∞ микс',
@@ -128,6 +177,13 @@ const ru: Translations = {
loadingMore: 'Загрузка радио…', loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения', doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди', removeFromQueue: 'Убрать из очереди',
menu: {
options: 'Параметры трека',
playNow: 'Воспроизвести сейчас',
moveNext: 'Сделать следующим',
info: 'Информация о треке',
remove: 'Убрать из очереди',
},
}, },
track: { track: {
menu: { menu: {
@@ -135,17 +191,103 @@ const ru: Translations = {
playNow: 'Играть сейчас', playNow: 'Играть сейчас',
playNext: 'Следующим', playNext: 'Следующим',
addToQueue: 'Добавить в очередь', addToQueue: 'Добавить в очередь',
info: 'Информация о треке',
addToPlaylist: 'Добавить в плейлист…', addToPlaylist: 'Добавить в плейлист…',
editMetadata: 'Редактировать метаданные', editMetadata: 'Редактировать метаданные',
download: 'Скачать', download: 'Скачать',
delete: 'Удалить', delete: 'Удалить',
}, },
}, },
trackInfo: {
title: 'О треке',
open: 'Информация о треке',
close: 'Закрыть',
notFound: 'Трек не найден',
play: 'Играть',
addToQueue: 'В очередь',
editMetadata: 'Метаданные',
liked: 'В избранном',
trackOf: '№ {{n}} из {{total}}',
kbps: '{{n}} кбит/с',
sections: {
status: 'Статус',
general: 'Основное',
file: 'Файл',
identifiers: 'Идентификаторы',
},
fields: {
artist: 'Исполнитель',
album: 'Альбом',
trackNumber: 'Трек',
disc: 'Диск',
year: 'Год',
genre: 'Жанр',
duration: 'Длительность',
format: 'Формат',
bitrate: 'Битрейт',
size: 'Размер',
source: 'Источник',
added: 'Добавлен',
enriched: 'Обогащён',
trackId: 'ID трека',
albumId: 'ID альбома',
artistId: 'ID исполнителя',
},
},
common: { common: {
error: 'Что-то пошло не так', error: 'Что-то пошло не так',
retry: 'Повторить', retry: 'Повторить',
comingSoon: 'Скоро', comingSoon: 'Скоро',
back: 'Назад', back: 'Назад',
offlineBanner:
'Нет связи с сервером — показаны локально доступные данные, только для чтения.',
},
storage: {
subtitle: 'Всё, что хранит этот инстанс',
device: 'На этом устройстве',
server: 'На сервере',
audioCache: 'Кэш аудио',
audioCacheUsage: 'Занято {{used}} из {{max}}',
cachedTracks: '{{n}} треков сохранено офлайн',
audioCacheUnavailable:
'Офлайн-кэш аудио недоступен (service worker не активен).',
offlineLibrary: 'Офлайн-библиотека',
offlineLibraryMeta:
'{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн',
serverUnreachable: 'Сервер недоступен — показано только это устройство.',
emptyTitle: 'Пока ничего не сохранено',
emptyDesc:
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
disk: 'Диск',
diskUsage: 'Занято {{used}} из {{total}}',
diskFree: 'Свободно {{free}}',
diskLibraryShare: 'Библиотека занимает {{percent}}% всего диска',
diskUnknown: 'Объектное хранилище — фиксированного диска нет',
footprint: 'Объём библиотеки',
tracks: 'Треки',
artists: 'Исполнители',
albums: 'Альбомы',
playtime: 'Общая длительность',
avgTrackSize: 'Средний размер трека',
largestTrack: 'Самый большой трек',
formats: 'Форматы',
sources: 'Откуда взято',
metadataHealth: 'Состояние метаданных',
topGenres: 'Топ жанров',
noGenres: 'Жанры пока не указаны',
funFacts: 'Интересные факты',
factPlaytime:
'Нажмите play и уходите — библиотека играет {{duration}} без остановки.',
factFootprint: '{{size}} музыки в {{tracks}} треках.',
factGenre: 'Чаще всего встречается жанр {{genre}} ({{count}} треков).',
factAvg: 'Средний трек весит {{size}}.',
factSince: 'Коллекция собирается с {{date}}.',
status: {
enriched: 'Обогащено',
manual: 'Вручную',
pending: 'В ожидании',
failed: 'Ошибка',
},
}, },
pages: { pages: {
admin: 'Администрирование', admin: 'Администрирование',
@@ -198,6 +340,10 @@ const ru: Translations = {
clearCompleted: 'Убрать завершённые', clearCompleted: 'Убрать завершённые',
retry: 'Повторить', retry: 'Повторить',
editMetadata: 'Изменить метаданные', editMetadata: 'Изменить метаданные',
recent: {
title: 'Недавно загруженные',
empty: 'Пока ничего не загружено.',
},
metadataPending: metadataPending:
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.', 'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
unknownArtist: 'Unknown Artist · метаданные в ожидании', unknownArtist: 'Unknown Artist · метаданные в ожидании',
@@ -209,6 +355,96 @@ const ru: Translations = {
error: 'Ошибка', error: 'Ошибка',
}, },
}, },
discover: {
title: 'Поиск и скачивание',
subtitle:
'Находите музыку в подключённых источниках и добавляйте в библиотеку.',
searchPlaceholder: 'Найти трек, исполнителя или альбом…',
searchButton: 'Найти',
allSources: 'Все источники',
noSources:
'Источники скачивания не настроены. Включите источник (например, YouTube Music) на сервере, чтобы искать и скачивать.',
startTitle: 'Начните с поиска',
startDesc: 'Здесь появятся результаты из подключённых источников.',
emptyTitle: 'Ничего не найдено',
emptyDesc: 'Попробуйте другой запрос или другой источник.',
searchError: 'Не удалось выполнить поиск. Попробуйте ещё раз.',
download: 'Скачать',
retryDownload: 'Повторить',
queued: 'В очереди',
inLibrary: 'В библиотеке',
viewDownloads: 'К загрузкам',
},
downloads: {
title: 'Загрузки',
subtitle: 'Активные, завершённые и неуспешные скачивания.',
activeCount: 'Активных: {{count}}',
sectionActive: 'В процессе',
sectionHistory: 'История',
emptyTitle: 'Пока нет загрузок',
emptyDesc: 'Найдите музыку в разделе «Поиск и скачивание».',
loadError: 'Не удалось загрузить список.',
failedBanner:
'Неуспешных скачиваний: {{count}}. Повторите их или проверьте источник на сервере.',
attempt: 'Попытка {{count}}',
open: 'Открыть',
retry: 'Повторить',
cancel: 'Отменить',
status: {
queued: 'В очереди',
downloading: 'Скачивание',
enriching: 'Обработка',
done: 'Готово',
failed: 'Ошибка',
},
},
metadata: {
status: {
pending: 'Обработка…',
enriched: 'Готово',
failed: 'Нет совпадения',
manual: 'Вручную',
},
statusHint: {
pending: 'Определяем метаданные…',
enriched: 'Метаданные определены',
failed: 'Не удалось определить метаданные',
manual: 'Изменено вручную — не обновляется автоматически',
},
},
metadataEditor: {
error: 'Не удалось загрузить трек',
saved: 'Метаданные сохранены.',
saveError: 'Не удалось сохранить метаданные.',
save: 'Сохранить',
fields: {
title: 'Название',
artist: 'Исполнитель',
album: 'Альбом',
year: 'Год',
genre: 'Жанр',
trackNumber: 'Номер трека',
},
autoEnrich: {
title: 'Поиск по AcoustID',
hint: 'Определить трек по аудио-отпечатку.',
findMatches: 'Найти совпадения',
reEnrich: 'Повторить обогащение',
enqueued: 'Обогащение запущено — обновите через момент.',
error: 'Не удалось найти совпадения.',
noMatches: 'Совпадений не найдено.',
},
matches: {
use: 'Использовать',
unknownTitle: 'Неизвестное название',
},
diff: {
title: 'Применить это совпадение?',
noChanges: 'Нет изменений относительно текущих значений.',
cancel: 'Отмена',
apply: 'Применить',
},
},
}; };
export default ru; export default ru;
+1
View File
@@ -17,6 +17,7 @@ import './api/endpoints/auth';
import './api/endpoints/library'; import './api/endpoints/library';
import './api/endpoints/playlists'; import './api/endpoints/playlists';
import './api/endpoints/downloads'; import './api/endpoints/downloads';
import './api/endpoints/search';
import './api/endpoints/likes'; import './api/endpoints/likes';
import './api/endpoints/storage'; import './api/endpoints/storage';
import './api/endpoints/admin'; import './api/endpoints/admin';
+24
View File
@@ -16,6 +16,30 @@ export function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} }
export function formatDateTime(iso: string | undefined): string | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d);
}
/** Human "X days Y hours" style for big spans (e.g. total library playtime).
* Shows the two most-significant non-zero units; falls back to "0m". */
export function formatLongDuration(seconds: number): string {
if (seconds <= 0) return '0m';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (mins && parts.length < 2) parts.push(`${mins}m`);
return parts.slice(0, 2).join(' ') || '0m';
}
export function formatCount(n: number): string { export function formatCount(n: number): string {
if (n < 1000) return String(n); if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
+7 -2
View File
@@ -44,7 +44,9 @@ const DownloadsManagerPage = lazy(() =>
})), })),
); );
const UploadPage = lazy(() => const UploadPage = lazy(() =>
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })), import('../features/upload/UploadPage').then((m) => ({
default: m.UploadPage,
})),
); );
const MetadataEditorPage = lazy(() => const MetadataEditorPage = lazy(() =>
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({ import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
@@ -126,7 +128,10 @@ export function AppRoutes() {
{/* Storage */} {/* Storage */}
<Route path="/storage" element={<StoragePage />} /> <Route path="/storage" element={<StoragePage />} />
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} /> <Route
path="/storage/maintenance"
element={<StorageMaintenancePage />}
/>
{/* Queue (narrow viewports) */} {/* Queue (narrow viewports) */}
<Route path="/queue" element={<QueuePage />} /> <Route path="/queue" element={<QueuePage />} />
+7
View File
@@ -1,6 +1,8 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { api } from '../api'; import { api } from '../api';
import authReducer from './slices/auth'; import authReducer from './slices/auth';
import connectionReducer from './slices/connection';
import playerReducer from './slices/player'; import playerReducer from './slices/player';
import queueReducer from './slices/queue'; import queueReducer from './slices/queue';
import uiReducer from './slices/ui'; import uiReducer from './slices/ui';
@@ -11,6 +13,7 @@ export const store = configureStore({
reducer: { reducer: {
[api.reducerPath]: api.reducer, [api.reducerPath]: api.reducer,
auth: authReducer, auth: authReducer,
connection: connectionReducer,
player: playerReducer, player: playerReducer,
queue: queueReducer, queue: queueReducer,
ui: uiReducer, ui: uiReducer,
@@ -25,6 +28,10 @@ export const store = configureStore({
getDefaultMiddleware().concat(api.middleware), getDefaultMiddleware().concat(api.middleware),
}); });
// Enable refetchOnReconnect / refetchOnFocus by dispatching the browser's
// online + focus events into RTKQ (no-op without the api flags set in api/index).
setupListeners(store.dispatch);
// Flush queue/player changes back to localStorage (throttled). // Flush queue/player changes back to localStorage (throttled).
startPersistence(store); startPersistence(store);
+12 -12
View File
@@ -8,14 +8,8 @@
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`). * Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
*/ */
import { instanceStorage } from '../config/instances'; import { instanceStorage } from '../config/instances';
import { import { queueInitialState, type QueueState } from './slices/queue';
queueInitialState, import { playerInitialState, type PlayerState } from './slices/player';
type QueueState,
} from './slices/queue';
import {
playerInitialState,
type PlayerState,
} from './slices/player';
import type { RootState } from './index'; import type { RootState } from './index';
const QUEUE_KEY = 'queue'; const QUEUE_KEY = 'queue';
@@ -26,11 +20,17 @@ const PLAYER_KEY = 'player';
// transient UI, so they are intentionally left out. // transient UI, so they are intentionally left out.
type PersistedQueue = Pick< type PersistedQueue = Pick<
QueueState, QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName' | 'entries'
| 'currentIndex'
| 'source'
| 'sourceId'
| 'sourceName'
| 'shuffle'
| 'loop'
>; >;
type PersistedPlayer = Pick< type PersistedPlayer = Pick<
PlayerState, PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle' 'currentTrackId' | 'position' | 'volume' | 'muted'
>; >;
function pickQueue(state: QueueState): PersistedQueue { function pickQueue(state: QueueState): PersistedQueue {
@@ -40,6 +40,8 @@ function pickQueue(state: QueueState): PersistedQueue {
source: state.source, source: state.source,
sourceId: state.sourceId, sourceId: state.sourceId,
sourceName: state.sourceName, sourceName: state.sourceName,
shuffle: state.shuffle,
loop: state.loop,
}; };
} }
@@ -49,8 +51,6 @@ function pickPlayer(state: PlayerState): PersistedPlayer {
position: state.position, position: state.position,
volume: state.volume, volume: state.volume,
muted: state.muted, muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
}; };
} }
+16 -2
View File
@@ -22,13 +22,21 @@ type QueryEntry = ApiState['queries'][string];
* carry no usable data and subscriptions are rebuilt by components on mount. * carry no usable data and subscriptions are rebuilt by components on mount.
* Mutation results are never restored. * Mutation results are never restored.
*/ */
const EMPTY_PROVIDED = { tags: {}, keys: {} };
function snapshot(apiState: ApiState): RehydrateApiPayload { function snapshot(apiState: ApiState): RehydrateApiPayload {
const queries: Record<string, unknown> = {}; const queries: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(apiState.queries)) { for (const [key, entry] of Object.entries(apiState.queries)) {
const q = entry as QueryEntry | undefined; const q = entry as QueryEntry | undefined;
if (q && q.status === 'fulfilled') queries[key] = q; if (q && q.status === 'fulfilled') queries[key] = q;
} }
return { queries, mutations: {} }; // Carry `provided` along so RTKQ can re-register invalidation tags for the
// restored entries; it is also required structurally (see RehydrateApiPayload).
return {
queries,
mutations: {},
provided: apiState.provided ?? EMPTY_PROVIDED,
};
} }
function load(): RehydrateApiPayload | null { function load(): RehydrateApiPayload | null {
@@ -37,7 +45,13 @@ function load(): RehydrateApiPayload | null {
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>; const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
if (!parsed.queries) return null; if (!parsed.queries) return null;
return { queries: parsed.queries, mutations: {} }; // `provided` may be absent in snapshots written before this field existed —
// default it so the invalidation slice doesn't crash on `provided.tags`.
return {
queries: parsed.queries,
mutations: {},
provided: parsed.provided ?? EMPTY_PROVIDED,
};
} catch { } catch {
return null; return null;
} }
+97
View File
@@ -0,0 +1,97 @@
/*
* Offline library composition. When the active backend is unreachable, a single
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
* can't rely on it to render the library. Instead we compose the "locally
* available" library from *every* fulfilled entry in the RTK Query cache —
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
* this session. This is read-only derived data, not a server-data slice copy:
* it reads straight from the RTKQ cache the architecture already owns.
*/
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../index';
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
interface CacheEntry {
status: string;
endpointName?: string;
data?: unknown;
}
const selectQueries = (state: RootState): Record<string, unknown> =>
state.api.queries;
function fulfilled(queries: Record<string, unknown>): CacheEntry[] {
const out: CacheEntry[] = [];
for (const entry of Object.values(queries)) {
const e = entry as CacheEntry | undefined;
if (e && e.status === 'fulfilled' && e.data != null) out.push(e);
}
return out;
}
/** Every track known locally, deduped by id (last write wins). */
export const selectLocalTracks = createSelector(
selectQueries,
(queries): Track[] => {
const byId = new Map<string, Track>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getTracks':
for (const t of (e.data as PaginatedResponse<Track>).items)
byId.set(t.id, t);
break;
case 'getAlbumTracks':
case 'getArtistTracks':
for (const t of e.data as Track[]) byId.set(t.id, t);
break;
case 'getTrack':
byId.set((e.data as Track).id, e.data as Track);
break;
}
}
return [...byId.values()];
},
);
/** Every album known locally, deduped by id. */
export const selectLocalAlbums = createSelector(
selectQueries,
(queries): Album[] => {
const byId = new Map<string, Album>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getAlbums':
for (const a of (e.data as PaginatedResponse<Album>).items)
byId.set(a.id, a);
break;
case 'getArtistAlbums':
for (const a of e.data as Album[]) byId.set(a.id, a);
break;
case 'getAlbum':
byId.set((e.data as Album).id, e.data as Album);
break;
}
}
return [...byId.values()];
},
);
/** Every artist known locally, deduped by id. */
export const selectLocalArtists = createSelector(
selectQueries,
(queries): Artist[] => {
const byId = new Map<string, Artist>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getArtists':
for (const a of (e.data as PaginatedResponse<Artist>).items)
byId.set(a.id, a);
break;
case 'getArtist':
byId.set((e.data as Artist).id, e.data as Artist);
break;
}
}
return [...byId.values()];
},
);
+28
View File
@@ -0,0 +1,28 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type ConnectionStatus =
| 'connected'
| 'connecting'
| 'disconnected'
| 'error';
export interface ConnectionState {
status: ConnectionStatus;
}
export const connectionInitialState: ConnectionState = {
status: 'connecting',
};
export const connectionSlice = createSlice({
name: 'connection',
initialState: connectionInitialState,
reducers: {
setConnectionStatus(state, action: PayloadAction<ConnectionStatus>) {
state.status = action.payload;
},
},
});
export const { setConnectionStatus } = connectionSlice.actions;
export default connectionSlice.reducer;
-20
View File
@@ -1,7 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type RepeatMode = 'none' | 'one' | 'all';
export interface PlayerState { export interface PlayerState {
currentTrackId: string | null; currentTrackId: string | null;
isPlaying: boolean; isPlaying: boolean;
@@ -9,9 +7,6 @@ export interface PlayerState {
duration: number; duration: number;
volume: number; volume: number;
muted: boolean; muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isNowPlayingOpen: boolean;
isQueueOpen: boolean; isQueueOpen: boolean;
} }
@@ -22,9 +17,6 @@ export const playerInitialState: PlayerState = {
duration: 0, duration: 0,
volume: 0.78, volume: 0.78,
muted: false, muted: false,
repeat: 'none',
shuffle: false,
isNowPlayingOpen: false,
isQueueOpen: false, isQueueOpen: false,
}; };
@@ -60,15 +52,6 @@ export const playerSlice = createSlice({
toggleMute(state) { toggleMute(state) {
state.muted = !state.muted; state.muted = !state.muted;
}, },
setRepeat(state, action: PayloadAction<RepeatMode>) {
state.repeat = action.payload;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleNowPlaying(state) {
state.isNowPlayingOpen = !state.isNowPlayingOpen;
},
toggleQueue(state) { toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen; state.isQueueOpen = !state.isQueueOpen;
}, },
@@ -84,9 +67,6 @@ export const {
setDuration, setDuration,
setVolume, setVolume,
toggleMute, toggleMute,
setRepeat,
toggleShuffle,
toggleNowPlaying,
toggleQueue, toggleQueue,
} = playerSlice.actions; } = playerSlice.actions;
export default playerSlice.reducer; export default playerSlice.reducer;
+27 -1
View File
@@ -23,6 +23,8 @@ export interface QueueState {
source: QueueSource; source: QueueSource;
sourceId: string | null; sourceId: string | null;
sourceName: string | null; sourceName: string | null;
shuffle: boolean;
loop: boolean;
} }
export const queueInitialState: QueueState = { export const queueInitialState: QueueState = {
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
source: 'manual', source: 'manual',
sourceId: null, sourceId: null,
sourceName: null, sourceName: null,
shuffle: false,
loop: false,
}; };
export const queueSlice = createSlice({ export const queueSlice = createSlice({
@@ -59,6 +63,11 @@ export const queueSlice = createSlice({
addNextInQueue(state, action: PayloadAction<QueueEntry>) { addNextInQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.splice(state.currentIndex + 1, 0, action.payload); state.entries.splice(state.currentIndex + 1, 0, action.payload);
}, },
playNow(state, action: PayloadAction<QueueEntry>) {
const insertAt = state.currentIndex + 1;
state.entries.splice(insertAt, 0, action.payload);
state.currentIndex = insertAt;
},
removeFromQueue(state, action: PayloadAction<number>) { removeFromQueue(state, action: PayloadAction<number>) {
state.entries.splice(action.payload, 1); state.entries.splice(action.payload, 1);
if (action.payload < state.currentIndex) state.currentIndex--; if (action.payload < state.currentIndex) state.currentIndex--;
@@ -77,7 +86,15 @@ export const queueSlice = createSlice({
state.currentIndex = action.payload; state.currentIndex = action.payload;
}, },
nextTrack(state) { nextTrack(state) {
if (state.currentIndex < state.entries.length - 1) state.currentIndex++; if (state.shuffle && state.entries.length > 1) {
let next = state.currentIndex;
while (next === state.currentIndex) {
next = Math.floor(Math.random() * state.entries.length);
}
state.currentIndex = next;
} else if (state.currentIndex < state.entries.length - 1) {
state.currentIndex++;
}
}, },
prevTrack(state) { prevTrack(state) {
if (state.currentIndex > 0) state.currentIndex--; if (state.currentIndex > 0) state.currentIndex--;
@@ -86,6 +103,12 @@ export const queueSlice = createSlice({
state.entries = []; state.entries = [];
state.currentIndex = -1; state.currentIndex = -1;
}, },
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleLoop(state) {
state.loop = !state.loop;
},
}, },
}); });
@@ -93,11 +116,14 @@ export const {
setQueue, setQueue,
addToQueue, addToQueue,
addNextInQueue, addNextInQueue,
playNow,
removeFromQueue, removeFromQueue,
moveInQueue, moveInQueue,
goToIndex, goToIndex,
nextTrack, nextTrack,
prevTrack, prevTrack,
clearQueue, clearQueue,
toggleShuffle,
toggleLoop,
} = queueSlice.actions; } = queueSlice.actions;
export default queueSlice.reducer; export default queueSlice.reducer;
+11
View File
@@ -4,12 +4,15 @@ interface UiState {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
activeModal: string | null; activeModal: string | null;
activeTrackContextMenuId: string | null; activeTrackContextMenuId: string | null;
/** Track whose info drawer is open (rightmost drawer); null = closed. */
trackInfoId: string | null;
} }
const initialState: UiState = { const initialState: UiState = {
sidebarCollapsed: false, sidebarCollapsed: false,
activeModal: null, activeModal: null,
activeTrackContextMenuId: null, activeTrackContextMenuId: null,
trackInfoId: null,
}; };
export const uiSlice = createSlice({ export const uiSlice = createSlice({
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
setActiveContextMenu(state, action: PayloadAction<string | null>) { setActiveContextMenu(state, action: PayloadAction<string | null>) {
state.activeTrackContextMenuId = action.payload; state.activeTrackContextMenuId = action.payload;
}, },
openTrackInfo(state, action: PayloadAction<string>) {
state.trackInfoId = action.payload;
},
closeTrackInfo(state) {
state.trackInfoId = null;
},
}, },
}); });
@@ -40,5 +49,7 @@ export const {
openModal, openModal,
closeModal, closeModal,
setActiveContextMenu, setActiveContextMenu,
openTrackInfo,
closeTrackInfo,
} = uiSlice.actions; } = uiSlice.actions;
export default uiSlice.reducer; export default uiSlice.reducer;
+5
View File
@@ -28,6 +28,11 @@ body {
margin: 0; margin: 0;
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--fg-1); color: var(--fg-1);
/* Paint the themed background immediately. The inline theme script in
index.html (see rsbuild.config.ts) sets [data-theme] before first paint, so
--color-bg resolves to the right value here before React mounts #root and
layers the .modern-sk-felt grain on top — no flash of white. */
background: var(--color-bg);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
+318 -42
View File
@@ -404,6 +404,55 @@
font-size: 10px; font-size: 10px;
} }
/* ---- playing indicator ("hopping bars" equalizer, YTM-style) ---- */
.playing-bars {
display: inline-flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.playing-bars span {
display: block;
width: 3px;
background: var(--lime);
border-radius: 1px;
height: 30%;
animation: playing-bar-bounce 1s ease-in-out infinite;
}
.playing-bars span:nth-child(1) {
animation-delay: -0.9s;
}
.playing-bars span:nth-child(2) {
animation-delay: -0.3s;
}
.playing-bars span:nth-child(3) {
animation-delay: -0.6s;
}
.playing-bars.paused span {
animation-play-state: paused;
height: 100%;
}
.spin {
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes playing-bar-bounce {
0%,
100% {
height: 30%;
}
50% {
height: 100%;
}
}
/* ============================================================ /* ============================================================
PLAYER BAR PLAYER BAR
============================================================ */ ============================================================ */
@@ -602,38 +651,9 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 18px; padding: 12px 12px 18px;
} }
.qd-now {
display: flex;
gap: 11px;
align-items: center;
padding: 10px;
border-radius: var(--r-md);
background: linear-gradient(
180deg,
rgba(190, 242, 100, 0.13),
rgba(190, 242, 100, 0.05)
);
border: 1px solid rgba(190, 242, 100, 0.2);
margin-bottom: 14px;
}
.qd-now .qt {
min-width: 0;
flex: 1;
}
.qd-now .qt .t {
font-size: 13px;
font-weight: 600;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qd-now .qt .r {
font-size: 11px;
color: var(--fg-3);
}
.qrow { .qrow {
display: flex; display: flex;
gap: 11px; gap: 11px;
@@ -649,6 +669,24 @@
.qrow:hover { .qrow:hover {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
.qrow.current {
background: linear-gradient(
180deg,
rgba(190, 242, 100, 0.13),
rgba(190, 242, 100, 0.05)
);
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
}
.qrow.dragging {
z-index: 1;
cursor: grabbing;
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.qrow.current .qt .t {
color: var(--lime);
font-weight: 600;
}
.qrow .grip { .qrow .grip {
color: var(--fg-3); color: var(--fg-3);
font-size: 15px; font-size: 15px;
@@ -663,23 +701,46 @@
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--fg-1); color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.qrow .qt .r { .qrow .qt .r {
font-size: 11px; font-size: 11px;
color: var(--fg-3); color: var(--fg-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
} }
.qrow .qt .r .ph {
color: var(--lime); /* News-ticker text: clips by default, ping-pong scrolls only when it overflows
font-size: 11px; (the .on class is set by the Marquee component after measuring). */
.marquee {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.marquee-inner {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
overflow: hidden;
vertical-align: bottom;
}
.marquee.on .marquee-inner {
max-width: none;
animation: marquee-pingpong 9s ease-in-out infinite alternate;
}
@keyframes marquee-pingpong {
0%,
12% {
transform: translateX(0);
}
88%,
100% {
transform: translateX(var(--mq-shift, 0));
}
}
@media (prefers-reduced-motion: reduce) {
.marquee.on .marquee-inner {
animation: none;
}
} }
.qd-radio { .qd-radio {
margin-bottom: 14px; margin-bottom: 14px;
@@ -724,6 +785,164 @@
font-size: 12px; font-size: 12px;
} }
/* ============================================================
TRACK INFO DRAWER (rightmost — sits right of the queue drawer)
============================================================ */
/* Same width-collapse pattern as .qd. Rendered after QueuePanel in AppShell so
when both are open this is the rightmost panel. */
.tid {
width: 360px;
flex-shrink: 0;
overflow: hidden;
border-left: 1px solid var(--hair);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.24));
transition:
width 0.24s var(--ease-out),
border-left-color 0.24s var(--ease-out);
}
.tid.closed {
width: 0;
border-left-color: transparent;
}
.tid-inner {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.tid-head {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--hair);
}
.tid-head h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--fg-1);
}
.tid-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px;
}
.tid-cover {
width: 100%;
aspect-ratio: 1 / 1;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
background: var(--steel-900);
box-shadow: var(--shadow-raised, 0 8px 24px rgba(0, 0, 0, 0.4));
}
.tid-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tid-title {
margin: 0 0 4px;
font-size: 19px;
font-weight: 700;
color: var(--fg-1);
line-height: 1.25;
}
.tid-sub {
display: block;
font-size: 13px;
color: var(--fg-2);
text-decoration: none;
}
.tid-sub:hover {
color: var(--lime);
text-decoration: underline;
}
.tid-album {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
color: var(--fg-3);
}
.tid-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 4px;
}
.tid-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--hair);
}
.tid-section-label {
display: block;
margin-bottom: 10px;
}
.tid-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.tid-error {
margin: 8px 0 0;
font-size: 12px;
color: var(--ember, #e9572b);
}
.tid-row {
display: flex;
gap: 12px;
padding: 5px 0;
font-size: 13px;
}
.tid-row-k {
flex-shrink: 0;
width: 96px;
color: var(--fg-3);
}
.tid-row-v {
min-width: 0;
flex: 1;
color: var(--fg-1);
text-align: right;
word-break: break-word;
}
.tid-row-v.mono {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
color: var(--fg-2);
}
/* On narrower viewports the drawer overlays the content instead of pushing it,
so the queue + info drawers don't squeeze the main screen. */
@media (max-width: 1180px) {
.app-body {
position: relative;
}
.tid {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 360px;
z-index: 30;
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.5);
transition: transform 0.24s var(--ease-out);
}
.tid.closed {
width: 360px;
transform: translateX(100%);
box-shadow: none;
}
}
/* ============================================================ /* ============================================================
PAGE HEADER + SECONDARY NAV (Settings, Admin) PAGE HEADER + SECONDARY NAV (Settings, Admin)
============================================================ */ ============================================================ */
@@ -764,3 +983,60 @@
.sb-sec-link.active { .sb-sec-link.active {
color: var(--fg-1); color: var(--fg-1);
} }
/* ============================================================
TRACK ROW — cover art play overlay
============================================================ */
.track-art {
position: relative;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
}
.track-art-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.5);
color: var(--fg-1);
font-size: 16px;
cursor: pointer;
opacity: 0;
transition: opacity var(--dur-quick);
}
.track-art:hover .track-art-play {
opacity: 1;
}
.track-art-play:hover {
color: var(--lime);
}
/* Now-playing overlay shown on a cover when its track is the active one
(track lists and the queue panel both use this). */
.cover-playing {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.track-art:hover .cover-playing {
opacity: 0;
}
/* Queue row cover-art wrapper, sized to match the 36px ArtTile */
.qart {
position: relative;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
}
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from '@rstest/core';
import {
selectLocalTracks,
selectLocalAlbums,
selectLocalArtists,
} from '../src/store/selectors/localLibrary';
import type { RootState } from '../src/store/index';
function stateWith(queries: Record<string, unknown>): RootState {
return { api: { queries } } as unknown as RootState;
}
const track = (id: string, over: Record<string, unknown> = {}) => ({
id,
title: `Track ${id}`,
artistName: 'A',
albumTitle: 'Alb',
...over,
});
test('selectLocalTracks unions getTracks pages, list endpoints and single tracks', () => {
const state = stateWith({
'getTracks(undefined)': {
status: 'fulfilled',
endpointName: 'getTracks',
data: { items: [track('1'), track('2')], total: 2 },
},
'getArtistTracks("x")': {
status: 'fulfilled',
endpointName: 'getArtistTracks',
data: [track('2'), track('3')], // 2 is a dupe
},
'getTrack("4")': {
status: 'fulfilled',
endpointName: 'getTrack',
data: track('4'),
},
});
const ids = selectLocalTracks(state)
.map((t) => t.id)
.sort();
expect(ids).toEqual(['1', '2', '3', '4']);
});
test('selectLocalTracks ignores pending/rejected and null-data entries', () => {
const state = stateWith({
'getTracks(a)': {
status: 'rejected',
endpointName: 'getTracks',
data: undefined,
},
'getTracks(b)': { status: 'pending', endpointName: 'getTracks' },
'getTracks(c)': {
status: 'fulfilled',
endpointName: 'getTracks',
data: { items: [track('9')], total: 1 },
},
});
expect(selectLocalTracks(state).map((t) => t.id)).toEqual(['9']);
});
test('selectLocalAlbums and selectLocalArtists compose and dedupe', () => {
const state = stateWith({
'getAlbums(undefined)': {
status: 'fulfilled',
endpointName: 'getAlbums',
data: { items: [{ id: 'al1' }, { id: 'al2' }], total: 2 },
},
'getArtistAlbums("x")': {
status: 'fulfilled',
endpointName: 'getArtistAlbums',
data: [{ id: 'al2' }], // dupe
},
'getArtists(undefined)': {
status: 'fulfilled',
endpointName: 'getArtists',
data: { items: [{ id: 'ar1' }], total: 1 },
},
});
expect(
selectLocalAlbums(state)
.map((a) => a.id)
.sort(),
).toEqual(['al1', 'al2']);
expect(selectLocalArtists(state).map((a) => a.id)).toEqual(['ar1']);
});
+24 -1
View File
@@ -20,7 +20,13 @@ beforeEach(() => {
function apiStateWith(queries: Record<string, unknown>) { function apiStateWith(queries: Record<string, unknown>) {
return { return {
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} }, api: {
queries,
mutations: {},
provided: {},
subscriptions: {},
config: {},
},
} as unknown as RootState; } as unknown as RootState;
} }
@@ -49,6 +55,23 @@ test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
}); });
}); });
test('rehydrate payload always carries `provided` (regression: RTKQ reads provided.tags)', () => {
// A snapshot persisted before `provided` existed must not crash RTKQ's
// invalidation slice, which does `Object.entries(provided.tags ?? {})`.
instanceStorage.set(
'rtkq',
JSON.stringify({
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [] } },
mutations: {},
}),
);
const dispatched: Array<{ payload: { provided?: unknown } }> = [];
rehydrateApiCache((a) =>
dispatched.push(a as { payload: { provided?: unknown } }),
);
expect(dispatched[0].payload.provided).toEqual({ tags: {}, keys: {} });
});
test('startApiPersistence saves only fulfilled queries after throttle', () => { test('startApiPersistence saves only fulfilled queries after throttle', () => {
rstest.useFakeTimers(); rstest.useFakeTimers();
let state = apiStateWith({}); let state = apiStateWith({});
+3 -3
View File
@@ -7,9 +7,9 @@ import {
} from '../public/sw-core.js'; } from '../public/sw-core.js';
test('trackIdFromUrl extracts the content id from a stream URL', () => { test('trackIdFromUrl extracts the content id from a stream URL', () => {
expect( expect(trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz')).toBe(
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'), 'abc123',
).toBe('abc123'); );
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull(); expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
}); });