Compare commits

34 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
Senko-san dacb8b9278 feat(api): real login + listening wired to the backend contract
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 faked ConnectPage login with a real /auth/login -> /auth/me
flow, including loading/error states. Add a backend-contract adapter layer
(api/mappers.ts) translating the backend's snake_case, lean *Out schemas and
{items,total,limit,offset} paging into the UI's camelCase domain types, so
swapping backends only touches the mappers.

- auth: chained login (tokens) + /auth/me (user); refresh on snake_case;
  expiresIn optional (reauth is 401-driven, backend sends no TTL)
- streaming: GET /stream/{id}?token= (token query param for <audio>); SW
  audio cache route + tests follow the path change (token stays cache-stable)
- library/playlists/likes/admin: correct paths (/tracks not /library/tracks),
  page/pageSize<->limit/offset, duration_seconds->durationMs, likes as
  append-only POST /likes event-log, admin is_superuser<->role
- downloads/storage: marked provisional (backend routes still stubs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:12:44 +03:00
Senko-san bcfb36d53e feat: make API base URL runtime-configurable
The PROD image baked PUBLIC_API_BASE_URL at build time (rsbuild inlines
PUBLIC_* vars), so a prebuilt image could only ever target a same-origin
'/api/v1' and needed a reverse proxy in front. Move the operator default to
runtime so one image can point at any backend origin without rebuilding.

- public/config.js: committed stub setting window.__APP_CONFIG__ = {}, used
  as the dev/build-time default and overwritten in prod at container start.
- rsbuild.config.ts: inject a classic (non-deferred) <script src="/config.js">
  into <head> so it runs before the deferred app bundle.
- src/config/env.ts: DEFAULT_API_BASE_URL now resolves
  window.__APP_CONFIG__.apiBaseUrl > import.meta.env.PUBLIC_API_BASE_URL >
  '/api/v1'. The user-chosen instance still wins over all of these.
- dockerfiles/30-runtime-config.sh: nginx /docker-entrypoint.d hook that
  regenerates /config.js from $PUBLIC_API_BASE_URL on every start.
- Dockerfile.prod: install the hook (build-time ARG is now just a fallback).
- nginx.conf: serve /config.js with Cache-Control: no-store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:40:59 +03:00
73 changed files with 6384 additions and 655 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
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Write the SPA's runtime operator config at container start.
#
# 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
# operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION).
# 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
: "${PUBLIC_API_BASE_URL:=/api/v1}"
: "${PUBLIC_ENABLE_REGISTRATION:=true}"
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
# 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"
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js"
+8 -2
View File
@@ -13,8 +13,9 @@ RUN npm ci
COPY . . COPY . .
# Bake the API base URL at build time (rsbuild inlines PUBLIC_* vars). # Build-time default for the API base URL (rsbuild inlines PUBLIC_* vars). This
# Same-origin default ('/api/v1') works behind any reverse proxy. # is only the *fallback* now — the real value is injected at container start by
# 30-runtime-config.sh, so the image can target any backend without a rebuild.
ARG PUBLIC_API_BASE_URL=/api/v1 ARG PUBLIC_API_BASE_URL=/api/v1
ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL
RUN npm run build RUN npm run build
@@ -25,5 +26,10 @@ FROM nginx:1.27-alpine AS runtime
COPY dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf COPY dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
# Runtime config injection: the nginx image runs /docker-entrypoint.d/*.sh
# before starting, regenerating /config.js from $PUBLIC_API_BASE_URL.
COPY dockerfiles/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+7
View File
@@ -14,6 +14,13 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# Runtime operator config — regenerated per container start, so it must
# never be cached or a redeployed backend URL would be ignored.
location = /config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
# SPA: every unknown path falls back to index.html (client-side router). # SPA: every unknown path falls back to index.html (client-side router).
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+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",
+8
View File
@@ -0,0 +1,8 @@
// Runtime operator configuration, read by the app before the bundle loads
// (see src/config/env.ts). In the PROD image this file is OVERWRITTEN at
// container start from $PUBLIC_API_BASE_URL (dockerfiles/30-runtime-config.sh),
// so one prebuilt image can target any backend origin without a rebuild.
//
// This committed stub is the local-dev / build-time default: it leaves the
// config empty so base-URL resolution falls back to the build-time env var.
window.__APP_CONFIG__ = {};
+2 -2
View File
@@ -19,8 +19,8 @@ export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB
export const COVER_CACHE = 'mcma-covers-v1'; export const COVER_CACHE = 'mcma-covers-v1';
export const MAX_COVERS = 600; export const MAX_COVERS = 600;
// Backend stream route: /api/v1/streaming/tracks/<trackId>?token=... // Backend stream route: /api/v1/stream/<trackId>?token=...
const STREAM_RE = /\/streaming\/tracks\/([^/?#]+)/; const STREAM_RE = /\/stream\/([^/?#]+)/;
/** The track (content) id from a stream URL, or null if it isn't one. */ /** The track (content) id from a stream URL, or null if it isn't one. */
export function trackIdFromUrl(url) { export function trackIdFromUrl(url) {
+6 -3
View File
@@ -2,7 +2,7 @@
* MCMA service worker — Tier 3 offline support: audio blob cache. * MCMA service worker — Tier 3 offline support: audio blob cache.
* *
* It sits between the app and the network for audio-stream requests only * It sits between the app and the network for audio-stream requests only
* (`/streaming/tracks/<id>`). The first time a track is streamed it's copied * (`/stream/<id>`). The first time a track is streamed it's copied
* into the Cache API (keyed by content id, token stripped); afterwards — or * into the Cache API (keyed by content id, token stripped); afterwards — or
* whenever the backend is unreachable — playback is served straight from the * whenever the backend is unreachable — playback is served straight from the
* cache, so already-heard tracks play with no network at all. * cache, so already-heard tracks play with no network at all.
@@ -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, {
+22
View File
@@ -36,6 +36,28 @@ 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
// 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
// overwritten from $PUBLIC_API_BASE_URL at container start in prod.
{
tag: 'script',
attrs: { src: '/config.js' },
head: true,
append: false,
},
{ {
tag: 'link', tag: 'link',
attrs: { rel: 'manifest', href: '/manifest.webmanifest' }, attrs: { rel: 'manifest', href: '/manifest.webmanifest' },
+9 -10
View File
@@ -34,24 +34,23 @@ export const baseQueryWithReauth: BaseQueryFn<
{ {
url: '/auth/refresh', url: '/auth/refresh',
method: 'POST', method: 'POST',
body: { refreshToken }, body: { refresh_token: refreshToken },
}, },
api, api,
extraOptions, extraOptions,
); );
if (refreshResult.data) { if (refreshResult.data) {
const { // Backend wire format is snake_case with no TTL (see auth.ts adapter).
accessToken, const { access_token, refresh_token } = refreshResult.data as {
refreshToken: newRefresh, access_token: string;
expiresIn, refresh_token: string;
} = refreshResult.data as {
accessToken: string;
refreshToken: string;
expiresIn: number;
}; };
api.dispatch( api.dispatch(
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }), setTokens({
accessToken: access_token,
refreshToken: refresh_token,
}),
); );
result = await rawBaseQuery()(args, api, extraOptions); result = await rawBaseQuery()(args, api, extraOptions);
} else { } else {
+21 -10
View File
@@ -1,33 +1,44 @@
import { api } from '../index'; import { api } from '../index';
import { toUser, type RawUser } from '../mappers';
import type { User } from '../types'; import type { User } from '../types';
/**
* Admin user management. The backend models authorization as `is_superuser` /
* `is_active` (no `role`/`email`); `toUser` maps superuser→role for the UI and
* the mutations translate role back to `is_superuser` on the way out.
*/
export const adminApi = api.injectEndpoints({ export const adminApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getUsers: build.query<User[], void>({ getUsers: build.query<User[], void>({
query: () => '/admin/users', query: () => '/admin/users',
transformResponse: (raw: RawUser[]) => raw.map(toUser),
providesTags: ['User'], providesTags: ['User'],
}), }),
createUser: build.mutation< createUser: build.mutation<
User, User,
{ { username: string; password: string; role: 'admin' | 'user' }
username: string;
password: string;
email?: string;
role: 'admin' | 'user';
}
>({ >({
query: (body) => ({ url: '/admin/users', method: 'POST', body }), query: ({ username, password, role }) => ({
url: '/admin/users',
method: 'POST',
body: { username, password, is_superuser: role === 'admin' },
}),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'], invalidatesTags: ['User'],
}), }),
updateUser: build.mutation< updateUser: build.mutation<
User, User,
{ id: string; role?: 'admin' | 'user'; email?: string } { id: string; role?: 'admin' | 'user'; isActive?: boolean }
>({ >({
query: ({ id, ...body }) => ({ query: ({ id, role, isActive }) => ({
url: `/admin/users/${id}`, url: `/admin/users/${id}`,
method: 'PATCH', method: 'PATCH',
body, body: {
is_superuser: role === undefined ? undefined : role === 'admin',
is_active: isActive,
},
}), }),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'], invalidatesTags: ['User'],
}), }),
deleteUser: build.mutation<void, string>({ deleteUser: build.mutation<void, string>({
+95 -11
View File
@@ -1,26 +1,110 @@
import { api } from '../index'; import { api } from '../index';
import type { LoginRequest, LoginResponse } from '../types'; import { toUser, type RawUser } from '../mappers';
import type {
AuthTokens,
LoginRequest,
LoginResponse,
RegisterRequest,
User,
} from '../types';
/**
* Auth seam over the backend's wire format: tokens-only login + a separate
* `/auth/me` for the user. Token mapping lives here; user mapping is shared with
* the admin endpoints via `toUser` in `mappers.ts`.
*/
/** `/auth/login` & `/auth/refresh` response shape. */
interface RawTokenResponse {
access_token: string;
refresh_token: string;
token_type?: string;
}
const toTokens = (raw: RawTokenResponse): AuthTokens => ({
accessToken: raw.access_token,
refreshToken: raw.refresh_token,
// No TTL on the wire — expiry is 401-driven (see baseQuery reauth).
});
export const authApi = api.injectEndpoints({ export const authApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
// Login is a two-call flow: POST /auth/login yields tokens, then GET
// /auth/me resolves the user. A queryFn chains both so callers get the
// unified { user, tokens } the UI expects in one await.
login: build.mutation<LoginResponse, LoginRequest>({ login: build.mutation<LoginResponse, LoginRequest>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }), async queryFn(body, _api, _extra, baseQuery) {
const tokenRes = await baseQuery({
url: '/auth/login',
method: 'POST',
body,
});
if (tokenRes.error) return { error: tokenRes.error };
const tokens = toTokens(tokenRes.data as RawTokenResponse);
// The access token isn't in the store yet, so attach it explicitly —
// baseQuery's prepareHeaders only injects what's already in auth state.
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, void>({ // Sign-up mirrors login: POST /auth/register returns a token pair (the
query: () => ({ url: '/auth/logout', method: 'POST' }), // 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 } };
},
}), }),
refreshToken: build.mutation< logout: build.mutation<void, { refreshToken: string }>({
{ accessToken: string; refreshToken: string; expiresIn: number }, query: ({ refreshToken }) => ({
{ refreshToken: string } url: '/auth/logout',
>({ method: 'POST',
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }), body: { refresh_token: refreshToken },
}),
}), }),
me: build.query<import('../types').User, void>({ refreshToken: build.mutation<AuthTokens, { refreshToken: string }>({
query: ({ refreshToken }) => ({
url: '/auth/refresh',
method: 'POST',
body: { refresh_token: refreshToken },
}),
transformResponse: (raw: RawTokenResponse) => toTokens(raw),
}),
me: build.query<User, void>({
query: () => '/auth/me', query: () => '/auth/me',
transformResponse: (raw: RawUser) => toUser(raw),
providesTags: ['User'], providesTags: ['User'],
}), }),
}), }),
overrideExisting: false, overrideExisting: false,
}); });
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi; export const {
useLoginMutation,
useRegisterMutation,
useLogoutMutation,
useRefreshTokenMutation,
useMeQuery,
} = authApi;
+81 -15
View File
@@ -1,24 +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';
interface ListParams {
status?: DownloadJob['status'];
/** Only the current user's jobs (backend `mine=true`). */
mine?: boolean;
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' }),
@@ -26,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,
@@ -34,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
export const { export const {
useGetDownloadsQuery, useGetDownloadsQuery,
useAddDownloadMutation, useGetDownloadQuery,
useCreateDownloadMutation,
useCancelDownloadMutation, useCancelDownloadMutation,
useRetryDownloadMutation, useRetryDownloadMutation,
} = downloadsApi; } = downloadsApi;
+128 -9
View File
@@ -1,16 +1,62 @@
import { api } from '../index'; import { api } from '../index';
import {
toAlbum,
toArtist,
toMetadataMatch,
toPage,
toTrack,
type RawAlbum,
type RawArtist,
type RawMetadataMatch,
type RawPaged,
type RawTrack,
} from '../mappers';
import type { import type {
Track, Track,
Album, Album,
Artist, Artist,
MetadataEdit,
MetadataMatch,
PaginatedResponse, PaginatedResponse,
LibraryFilters, LibraryFilters,
} from '../types'; } from '../types';
// The backend sorts on a small allow-list; map the UI's sort keys onto it
// (album/year aren't sortable server-side yet → fall back to recency).
const SORT_BY: Record<NonNullable<LibraryFilters['sortBy']>, string> = {
title: 'title',
artist: 'artist',
album: 'created_at',
year: 'created_at',
dateAdded: 'created_at',
};
/** UI page/pageSize → backend limit/offset. */
function paging(page?: number, pageSize?: number) {
const size = pageSize ?? 50;
return { limit: size, offset: ((page ?? 1) - 1) * size };
}
function trackParams(f: LibraryFilters) {
return {
q: f.search,
artist_id: f.artistId,
album_id: f.albumId,
source: f.source,
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
order: f.sortOrder,
...paging(f.page, f.pageSize),
};
}
export const libraryApi = api.injectEndpoints({ export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({ getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }), query: (filters) => ({
url: '/tracks',
params: trackParams(filters ?? {}),
}),
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
providesTags: (result) => providesTags: (result) =>
result result
? [ ? [
@@ -20,7 +66,8 @@ export const libraryApi = api.injectEndpoints({
: ['Track'], : ['Track'],
}), }),
getTrack: build.query<Track, string>({ getTrack: build.query<Track, string>({
query: (id) => `/library/tracks/${id}`, query: (id) => `/tracks/${id}`,
transformResponse: (raw: RawTrack) => toTrack(raw),
providesTags: (_r, _e, id) => [{ type: 'Track', id }], providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}), }),
getAlbums: build.query< getAlbums: build.query<
@@ -32,7 +79,15 @@ export const libraryApi = api.injectEndpoints({
pageSize?: number; pageSize?: number;
} | void } | void
>({ >({
query: (params) => ({ url: '/library/albums', params: params ?? {} }), query: (p) => ({
url: '/albums',
params: {
q: p?.search,
artist_id: p?.artistId,
...paging(p?.page, p?.pageSize),
},
}),
transformResponse: (raw: RawPaged<RawAlbum>) => toPage(raw, toAlbum),
providesTags: (result) => providesTags: (result) =>
result result
? [ ? [
@@ -42,11 +97,13 @@ export const libraryApi = api.injectEndpoints({
: ['Album'], : ['Album'],
}), }),
getAlbum: build.query<Album, string>({ getAlbum: build.query<Album, string>({
query: (id) => `/library/albums/${id}`, query: (id) => `/albums/${id}`,
transformResponse: (raw: RawAlbum) => toAlbum(raw),
providesTags: (_r, _e, id) => [{ type: 'Album', id }], providesTags: (_r, _e, id) => [{ type: 'Album', id }],
}), }),
getAlbumTracks: build.query<Track[], string>({ getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`, query: (albumId) => `/albums/${albumId}/tracks`,
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
providesTags: (_r, _e, albumId) => [ providesTags: (_r, _e, albumId) => [
{ type: 'Album', id: albumId }, { type: 'Album', id: albumId },
'Track', 'Track',
@@ -56,7 +113,11 @@ export const libraryApi = api.injectEndpoints({
PaginatedResponse<Artist>, PaginatedResponse<Artist>,
{ search?: string; page?: number; pageSize?: number } | void { search?: string; page?: number; pageSize?: number } | void
>({ >({
query: (params) => ({ url: '/library/artists', params: params ?? {} }), query: (p) => ({
url: '/artists',
params: { q: p?.search, ...paging(p?.page, p?.pageSize) },
}),
transformResponse: (raw: RawPaged<RawArtist>) => toPage(raw, toArtist),
providesTags: (result) => providesTags: (result) =>
result result
? [ ? [
@@ -69,23 +130,77 @@ export const libraryApi = api.injectEndpoints({
: ['Artist'], : ['Artist'],
}), }),
getArtist: build.query<Artist, string>({ getArtist: build.query<Artist, string>({
query: (id) => `/library/artists/${id}`, query: (id) => `/artists/${id}`,
transformResponse: (raw: RawArtist) => toArtist(raw),
providesTags: (_r, _e, id) => [{ type: 'Artist', id }], providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
}), }),
getArtistAlbums: build.query<Album[], string>({ getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`, query: (artistId) => `/artists/${artistId}/albums`,
transformResponse: (raw: RawPaged<RawAlbum>) => raw.items.map(toAlbum),
providesTags: (_r, _e, artistId) => [ providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId }, { type: 'Artist', id: artistId },
'Album', 'Album',
], ],
}), }),
getArtistTracks: build.query<Track[], string>({
query: (artistId) => `/artists/${artistId}/tracks`,
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Track',
],
}),
searchLibrary: build.query< searchLibrary: build.query<
{ tracks: Track[]; albums: Album[]; artists: Artist[] }, { tracks: Track[]; albums: Album[]; artists: Artist[] },
string string
>({ >({
query: (q) => ({ url: '/library/search', params: { q } }), query: (q) => ({ url: '/search/library', params: { q } }),
transformResponse: (raw: {
tracks: RawTrack[];
albums: RawAlbum[];
artists: RawArtist[];
}) => ({
tracks: raw.tracks.map(toTrack),
albums: raw.albums.map(toAlbum),
artists: raw.artists.map(toArtist),
}),
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,
}); });
@@ -99,5 +214,9 @@ export const {
useGetArtistsQuery, useGetArtistsQuery,
useGetArtistQuery, useGetArtistQuery,
useGetArtistAlbumsQuery, useGetArtistAlbumsQuery,
useGetArtistTracksQuery,
useSearchLibraryQuery, useSearchLibraryQuery,
useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation,
useEnrichTrackMutation,
} = libraryApi; } = libraryApi;
+58 -10
View File
@@ -1,20 +1,68 @@
import { api } from '../index'; import { api } from '../index';
/**
* Likes are an append-only event-log on the backend: state changes by POSTing a
* new `{track_id, value}` event, never by PUT/DELETE on a boolean. "Unlike" is
* just a `neutral` event superseding the prior `like`.
*/
type LikeValue = 'like' | 'dislike' | 'neutral';
interface RawLikeState {
track_id: string;
value: LikeValue;
updated_at: string;
}
export interface LikeState {
trackId: string;
value: LikeValue;
updatedAt: string;
}
const toLikeState = (r: RawLikeState): LikeState => ({
trackId: r.track_id,
value: r.value,
updatedAt: r.updated_at,
});
export const likesApi = api.injectEndpoints({ export const likesApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
likeTrack: build.mutation<void, string>({ setLike: build.mutation<LikeState, { trackId: string; value: LikeValue }>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }), query: ({ trackId, value }) => ({
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }], url: '/likes',
}), method: 'POST',
unlikeTrack: build.mutation<void, string>({ body: { track_id: trackId, value },
query: (trackId) => ({
url: `/likes/tracks/${trackId}`,
method: 'DELETE',
}), }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }], transformResponse: (raw: RawLikeState) => toLikeState(raw),
invalidatesTags: (_r, _e, { trackId }) => [
'Like',
{ type: 'Track', id: trackId },
],
}),
// Latest like state for a set of tracks (drives like buttons in lists).
getLikesState: build.query<LikeState[], string[]>({
query: (trackIds) => ({
url: '/likes/state',
params: { track_ids: trackIds.join(',') },
}),
transformResponse: (raw: RawLikeState[]) => raw.map(toLikeState),
providesTags: ['Like'],
}), }),
}), }),
overrideExisting: false, overrideExisting: false,
}); });
export const { useLikeTrackMutation, useUnlikeTrackMutation } = likesApi; const { useSetLikeMutation, useGetLikesStateQuery } = likesApi;
/** Convenience hook preserving the like/unlike call sites. */
export function useLikeActions() {
const [setLike, state] = useSetLikeMutation();
return {
like: (trackId: string) => setLike({ trackId, value: 'like' }),
unlike: (trackId: string) => setLike({ trackId, value: 'neutral' }),
dislike: (trackId: string) => setLike({ trackId, value: 'dislike' }),
state,
};
}
export { useSetLikeMutation, useGetLikesStateQuery };
+36 -11
View File
@@ -1,36 +1,61 @@
import { api } from '../index'; import { api } from '../index';
import {
toPage,
toPlaylist,
toTrack,
type RawPaged,
type RawPlaylist,
type RawTrack,
} from '../mappers';
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types'; import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
export const playlistsApi = api.injectEndpoints({ export const playlistsApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({ getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
query: () => '/playlists', query: () => '/playlists',
transformResponse: (raw: RawPaged<RawPlaylist>) =>
toPage(raw, toPlaylist),
providesTags: ['Playlist'], providesTags: ['Playlist'],
}), }),
getPlaylist: build.query<Playlist, string>({ getPlaylist: build.query<Playlist, string>({
query: (id) => `/playlists/${id}`, query: (id) => `/playlists/${id}`,
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }], providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
}), }),
getPlaylistTracks: build.query<PlaylistTrack[], string>({ getPlaylistTracks: build.query<PlaylistTrack[], string>({
query: (id) => `/playlists/${id}/tracks`, query: (id) => `/playlists/${id}/tracks`,
// The backend returns plain tracks in playlist order; position/addedAt
// aren't on TrackOut, so derive position from order and default addedAt.
transformResponse: (raw: RawPaged<RawTrack>) =>
raw.items.map((r, i) => ({
...toTrack(r),
position: i,
addedAt: r.created_at,
})),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'], providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}), }),
createPlaylist: build.mutation< createPlaylist: build.mutation<
Playlist, Playlist,
{ name: string; description?: string; isPublic?: boolean } { name: string; description?: string }
>({ >({
query: (body) => ({ url: '/playlists', method: 'POST', body }), query: ({ name, description }) => ({
url: '/playlists',
method: 'POST',
body: { name, description },
}),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: ['Playlist'], invalidatesTags: ['Playlist'],
}), }),
updatePlaylist: build.mutation< updatePlaylist: build.mutation<
Playlist, Playlist,
{ id: string; name?: string; description?: string; isPublic?: boolean } { id: string; name?: string; description?: string }
>({ >({
query: ({ id, ...body }) => ({ query: ({ id, name, description }) => ({
url: `/playlists/${id}`, url: `/playlists/${id}`,
method: 'PATCH', method: 'PATCH',
body, body: { name, description },
}), }),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }], invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}), }),
deletePlaylist: build.mutation<void, string>({ deletePlaylist: build.mutation<void, string>({
@@ -39,12 +64,12 @@ export const playlistsApi = api.injectEndpoints({
}), }),
addTrackToPlaylist: build.mutation< addTrackToPlaylist: build.mutation<
void, void,
{ playlistId: string; trackId: string } { playlistId: string; trackId: string; position?: number }
>({ >({
query: ({ playlistId, trackId }) => ({ query: ({ playlistId, trackId, position }) => ({
url: `/playlists/${playlistId}/tracks`, url: `/playlists/${playlistId}/tracks`,
method: 'POST', method: 'POST',
body: { trackId }, body: { track_id: trackId, position },
}), }),
invalidatesTags: (_r, _e, { playlistId }) => [ invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId }, { type: 'Playlist', id: playlistId },
@@ -52,10 +77,10 @@ export const playlistsApi = api.injectEndpoints({
}), }),
removeTrackFromPlaylist: build.mutation< removeTrackFromPlaylist: build.mutation<
void, void,
{ playlistId: string; trackId: string; position: number } { playlistId: string; trackId: string }
>({ >({
query: ({ playlistId, position }) => ({ query: ({ playlistId, trackId }) => ({
url: `/playlists/${playlistId}/tracks/${position}`, url: `/playlists/${playlistId}/tracks/${trackId}`,
method: 'DELETE', method: 'DELETE',
}), }),
invalidatesTags: (_r, _e, { playlistId }) => [ invalidatesTags: (_r, _e, { playlistId }) => [
+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;
+7 -1
View File
@@ -1,10 +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';
// `GET /storage` returns library + disk statistics (§A6). The maintenance
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `POST /storage/cleanup`) are still backend stubs 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>({
+36 -1
View File
@@ -1,8 +1,13 @@
import { getApiBaseUrl } from '../../config/runtime-config'; import { getApiBaseUrl } from '../../config/runtime-config';
/**
* Audio stream URL for the `<audio>` element. The access token rides as a query
* param because `<audio>` can't send an `Authorization` header; the backend
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
*/
export function getStreamUrl(trackId: string, token: string): string { export function getStreamUrl(trackId: string, token: string): string {
const base = getApiBaseUrl(); const base = getApiBaseUrl();
return `${base}/streaming/tracks/${trackId}?token=${encodeURIComponent(token)}`; return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
} }
export function getCoverUrl(artUrl: string | undefined): string | undefined { export function getCoverUrl(artUrl: string | undefined): string | undefined {
@@ -12,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',
+349
View File
@@ -0,0 +1,349 @@
/**
* Backend-contract adapters: the single place where the backend's wire format
* (snake_case, lean `*Out` schemas, `{items,total,limit,offset}` paging) is
* translated into the UI's internal camelCase domain types from `types.ts`.
*
* The endpoint files (`endpoints/*.ts`) own *paths and params*; this module owns
* *shape*. Swapping or mocking a backend means rewriting the mappers here —
* nothing in components, slices, or `types.ts` changes.
*
* Where the backend's lean schema omits a field the UI type carries (cover art,
* liked state, durations on albums…), the mapper fills a safe client default and
* says why inline. Those defaults are the contract's current edges, not bugs.
*/
import type {
Album,
Artist,
DownloadJob,
DownloadStatus,
ExternalSearchResult,
MetadataMatch,
MetadataStatus,
PaginatedResponse,
Playlist,
SourceInfo,
StorageStats,
Track,
User,
} 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) ----
export interface RawPaged<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RawUser {
id: string;
username: string;
is_superuser: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface RawTrack {
id: string;
title: string;
artist_id: string;
artist_name: string;
album_id: string | null;
album_title: string | null;
duration_seconds: number | null;
file_format: string;
file_size: number;
genre: string | null;
year: number | null;
track_number: number | null;
metadata_status: string;
metadata_error: string | null;
enriched_at: string | null;
has_cover: boolean;
source: 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 {
id: string;
title: string;
artist_id: string;
artist_name: string;
year: number | null;
track_count: number;
has_cover: boolean;
created_at: string;
}
export interface RawArtist {
id: string;
name: string;
album_count: number;
track_count: number;
created_at: string;
}
export interface RawPlaylist {
id: string;
name: string;
description: string | null;
owner_id: string;
version: number;
track_count: number;
created_at: string;
}
// ---- mappers ----
export const toUser = (r: RawUser): User => ({
id: r.id,
username: r.username,
// MVP role model: superuser → admin, everyone else → user.
role: r.is_superuser ? 'admin' : 'user',
createdAt: r.created_at,
});
export const toTrack = (r: RawTrack): Track => ({
id: r.id,
title: r.title,
artistId: r.artist_id,
artistName: r.artist_name,
albumId: r.album_id ?? '',
albumTitle: r.album_title ?? '',
// `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
// built in the component from the track id — see `getTrackCoverUrl`. Keep
// `albumArtUrl` undefined so callers fall back to generated tile art.
albumArtUrl: undefined,
hasCover: r.has_cover,
durationMs: (r.duration_seconds ?? 0) * 1000,
// 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.
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,
format: r.file_format,
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 => ({
id: r.id,
title: r.title,
artistId: r.artist_id,
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,
hasCover: r.has_cover,
year: r.year ?? undefined,
trackCount: r.track_count,
// AlbumOut has no aggregate duration; computed client-side from tracks when
// an album is opened.
totalDurationMs: 0,
});
export const toArtist = (r: RawArtist): Artist => ({
id: r.id,
name: r.name,
artUrl: undefined,
albumCount: r.album_count,
trackCount: r.track_count,
});
export const toPlaylist = (r: RawPlaylist): Playlist => ({
id: r.id,
name: r.name,
description: r.description ?? undefined,
ownerId: r.owner_id,
trackCount: r.track_count,
totalDurationMs: 0,
// No visibility concept on the backend yet — default private.
isPublic: false,
createdAt: r.created_at,
// PlaylistOut omits updated_at; mirror created_at until it's added.
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
* `{items,total,page,pageSize,hasMore}`, mapping each element.
*/
export const toPage = <R, T>(
raw: RawPaged<R>,
map: (r: R) => T,
): PaginatedResponse<T> => {
const pageSize = raw.limit || raw.items.length || 1;
return {
items: raw.items.map(map),
total: raw.total,
page: Math.floor(raw.offset / pageSize) + 1,
pageSize,
hasMore: raw.offset + raw.items.length < raw.total,
};
};
+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);
+133 -11
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 {
@@ -98,7 +188,9 @@ export interface User {
export interface AuthTokens { export interface AuthTokens {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn: number; // Optional: the backend's TokenResponse carries no TTL — expiry is driven by
// 401→refresh, not a client-side clock. Present only if a backend supplies it.
expiresIn?: number;
} }
export interface LoginRequest { export interface LoginRequest {
@@ -111,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;
@@ -124,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;
@@ -136,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={{
+40 -1
View File
@@ -1,2 +1,41 @@
/**
* Default backend base URL — the operator-set fallback used when no specific
* instance is active. Resolution order:
*
* 1. window.__APP_CONFIG__.apiBaseUrl — runtime, injected by the container
* at start from $PUBLIC_API_BASE_URL (see public/config.js). Lets one
* prebuilt image point at any backend origin without rebuilding.
* 2. import.meta.env.PUBLIC_API_BASE_URL — build-time default (rsbuild inlines
* PUBLIC_* vars). Used in local dev and as a baked fallback.
* 3. '/api/v1' — same-origin relative path (works behind a reverse proxy).
*
* The user's chosen instance still wins over all of these — see
* runtime-config.ts / instances.ts.
*/
function runtimeApiBaseUrl(): string | undefined {
if (typeof window === 'undefined') return undefined;
const value = window.__APP_CONFIG__?.apiBaseUrl;
return value ? value : undefined;
}
export const DEFAULT_API_BASE_URL = export const DEFAULT_API_BASE_URL =
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));
+10
View File
@@ -1,7 +1,17 @@
/// <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;
} }
// Runtime operator config injected by /config.js before the app bundle loads
// (written from $PUBLIC_API_BASE_URL at container start). See src/config/env.ts.
interface Window {
__APP_CONFIG__?: {
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>
);
} }
+396 -157
View File
@@ -1,18 +1,191 @@
import { useState } from 'react'; 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 { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
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 {
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';
import type { User } from '../../api/types';
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. */
function resolveLoginError(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 === 401) return 'connect.errors.badCredentials';
}
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();
@@ -21,41 +194,80 @@ 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 switchTo = (id: string) => { const [login, { isLoading: isLoggingIn }] = useLoginMutation();
const [register, { isLoading: isRegistering }] = useRegisterMutation();
const isLoading = isLoggingIn || isRegistering;
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) => {
removeInstance(id); 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); setRev((r) => r + 1);
}; };
const handleSubmit = (e: React.FormEvent) => { const handleLogout = (id: string) => {
e.preventDefault(); clearInstanceAuth(id);
setApiBaseUrl(apiUrl); setRev((r) => r + 1);
};
const fakeUser: User = { const handleRemove = (id: string) => {
id: 'dev-user', removeInstance(id);
username: username || 'dev', if (selectedId === id) setSelectedId(getActiveInstanceId());
role: 'admin', setRev((r) => r + 1);
createdAt: new Date().toISOString(), };
};
dispatch( const handleSubmit = async (e: React.FormEvent) => {
setTokens({ e.preventDefault();
accessToken: 'dev-token', if (!selectedInstance) return;
refreshToken: 'dev-refresh', setError(null);
expiresIn: 3600,
}), try {
); const action =
dispatch(setUser(fakeUser)); mode === 'register'
void navigate('/'); ? register({ username, password })
: login({ username, password });
const { user, tokens } = await action.unwrap();
dispatch(setTokens(tokens));
dispatch(setUser(user));
void navigate('/');
} catch (err) {
setError(
mode === 'register'
? resolveRegisterError(err)
: resolveLoginError(err),
);
}
}; };
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
@@ -100,145 +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>
<Callout variant="warning">
{t('connect.form.stubNote')}
</Callout>
<Button
type="submit"
variant="primary"
style={{ marginTop: '0.5rem' }}
> >
{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,
};
}
+260 -17
View File
@@ -25,18 +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.',
stubNote: submit: 'Log in',
'Stub mode — backend not wired. Connect signs in with a fake admin session, scoped to this instance.', 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: {
unreachable:
"Can't reach this server. Check the URL and that it's online.",
badCredentials: 'Incorrect username or password.',
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: {
@@ -69,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',
@@ -79,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',
@@ -92,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',
@@ -121,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: {
@@ -128,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',
@@ -191,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',
@@ -202,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>;
+257 -16
View File
@@ -27,18 +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 символов.',
stubNote: submit: 'Войти',
'Режим заглушки — сервер не подключён. Создаётся фиктивная сессия администратора для этого экземпляра.', submitting: 'Вход…',
registerSubmit: 'Зарегистрироваться',
registering: 'Регистрация…',
noAccount: 'Нет аккаунта?',
registerLink: 'Зарегистрироваться',
haveAccount: 'Уже есть аккаунт?',
signInLink: 'Войти',
},
errors: {
unreachable:
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
badCredentials: 'Неверное имя пользователя или пароль.',
generic: 'Не удалось войти. Попробуйте ещё раз.',
usernameTaken: 'Это имя пользователя уже занято.',
passwordTooShort: 'Пароль должен содержать не менее 8 символов.',
registrationDisabled: 'Регистрация на этом сервере отключена.',
registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.',
}, },
}, },
library: { library: {
@@ -71,6 +99,13 @@ const ru: Translations = {
artistRow: { artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков', meta: '{{albumCount}} альб. · {{trackCount}} треков',
}, },
offline: {
banner:
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
emptyTitle: 'Офлайн ничего нет',
emptyDesc:
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
},
}, },
album: { album: {
type: 'Альбом', type: 'Альбом',
@@ -81,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: 'Плейлист',
@@ -94,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: '∞ микс',
@@ -123,6 +177,13 @@ const ru: Translations = {
loadingMore: 'Загрузка радио…', loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения', doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди', removeFromQueue: 'Убрать из очереди',
menu: {
options: 'Параметры трека',
playNow: 'Воспроизвести сейчас',
moveNext: 'Сделать следующим',
info: 'Информация о треке',
remove: 'Убрать из очереди',
},
}, },
track: { track: {
menu: { menu: {
@@ -130,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: 'Администрирование',
@@ -193,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 · метаданные в ожидании',
@@ -204,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()];
},
);
+6 -2
View File
@@ -38,12 +38,16 @@ export const authSlice = createSlice({
action: PayloadAction<{ action: PayloadAction<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn: number; expiresIn?: number;
}>, }>,
) { ) {
state.accessToken = action.payload.accessToken; state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken; state.refreshToken = action.payload.refreshToken;
state.expiresAt = Date.now() + action.payload.expiresIn * 1000; // Backends that omit a TTL leave expiresAt null — reauth is 401-driven.
state.expiresAt =
action.payload.expiresIn != null
? Date.now() + action.payload.expiresIn * 1000
: null;
persistAuth(state); persistAuth(state);
}, },
setUser(state, action: PayloadAction<User>) { setUser(state, action: PayloadAction<User>) {
+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({});
+8 -8
View File
@@ -7,22 +7,22 @@ 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/streaming/tracks/abc123?token=xyz'), 'abc123',
).toBe('abc123'); );
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull(); expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
}); });
test('cacheKeyFor strips the token so the key is token-stable', () => { test('cacheKeyFor strips the token so the key is token-stable', () => {
const a = cacheKeyFor('https://host/api/v1/streaming/tracks/t1?token=AAA'); const a = cacheKeyFor('https://host/api/v1/stream/t1?token=AAA');
const b = cacheKeyFor('https://host/api/v1/streaming/tracks/t1?token=BBB'); const b = cacheKeyFor('https://host/api/v1/stream/t1?token=BBB');
expect(a).toBe(b); expect(a).toBe(b);
expect(a).toBe('https://host/api/v1/streaming/tracks/t1'); expect(a).toBe('https://host/api/v1/stream/t1');
}); });
test('cacheKeyFor keeps different origins distinct', () => { test('cacheKeyFor keeps different origins distinct', () => {
expect(cacheKeyFor('https://a/streaming/tracks/t1?token=x')).not.toBe( expect(cacheKeyFor('https://a/stream/t1?token=x')).not.toBe(
cacheKeyFor('https://b/streaming/tracks/t1?token=x'), cacheKeyFor('https://b/stream/t1?token=x'),
); );
}); });