Compare commits

25 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
55 changed files with 5070 additions and 407 deletions
+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
+70 -1
View File
@@ -8,6 +8,9 @@
"name": "mcma-webui", "name": "mcma-webui",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@olly/modern-sk": "^0.1.5", "@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",
@@ -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",
@@ -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",
+5 -1
View File
@@ -13,6 +13,9 @@
"test:watch": "rstest --watch" "test:watch": "rstest --watch"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@olly/modern-sk": "^0.1.5", "@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",
@@ -21,7 +24,8 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router": "^7.16.0" "react-router": "^7.16.0",
"use-debounce": "^10.1.1"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^2.0.7", "@rsbuild/core": "^2.0.7",
+5 -2
View File
@@ -60,7 +60,9 @@ async function handleAudio(event) {
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a // 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
// complete copy, then satisfy the original request (range-sliced if asked). // complete copy, then satisfy the original request (range-sliced if asked).
try { try {
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) }); const fullReq = new Request(req.url, {
headers: withoutRange(req.headers),
});
const resp = await fetch(fullReq); const resp = await fetch(fullReq);
if (isCacheable(resp)) { if (isCacheable(resp)) {
event.waitUntil(storeInCache(key, resp.clone())); event.waitUntil(storeInCache(key, resp.clone()));
@@ -135,7 +137,8 @@ async function buildRangeResponse(response, rangeHeader) {
const buf = await response.clone().arrayBuffer(); const buf = await response.clone().arrayBuffer();
const size = buf.byteLength; const size = buf.byteLength;
const r = parseRangeHeader(rangeHeader, size); const r = parseRangeHeader(rangeHeader, size);
const type = response.headers.get('content-type') || 'application/octet-stream'; const type =
response.headers.get('content-type') || 'application/octet-stream';
if (!r) { if (!r) {
return new Response(buf, { return new Response(buf, {
+80 -20
View File
@@ -1,30 +1,88 @@
import { api } from '../index'; import { api } from '../index';
import type { DownloadJob } from '../types'; import {
toDownloadJob,
toPage,
type RawDownloadJob,
type RawPaged,
} from '../mappers';
import type {
DownloadJob,
DownloadRequestResult,
PaginatedResponse,
} from '../types';
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they interface ListParams {
// return no body / no schema). The request shapes below are provisional and the status?: DownloadJob['status'];
// responses will need the same snake→camel mapper treatment as library/playlists /** Only the current user's jobs (backend `mine=true`). */
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not mine?: boolean;
// wire these into the UI until then. page?: number;
pageSize?: number;
}
interface RawCreateResponse {
already_in_library: boolean;
track_id: string | null;
job: RawDownloadJob | null;
}
export const downloadsApi = api.injectEndpoints({ export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getDownloads: build.query< getDownloads: build.query<
DownloadJob[], PaginatedResponse<DownloadJob>,
{ status?: DownloadJob['status'] } | void ListParams | void
>({ >({
query: (params) => ({ url: '/downloads', params: params ?? {} }), query: (params) => {
providesTags: ['Download'], const p = params ?? {};
const size = p.pageSize ?? 50;
return {
url: '/downloads',
params: {
status: p.status,
mine: p.mine ? true : undefined,
limit: size,
offset: ((p.page ?? 1) - 1) * size,
},
};
},
transformResponse: (raw: RawPaged<RawDownloadJob>) =>
toPage(raw, toDownloadJob),
providesTags: (result) =>
result
? [
...result.items.map(({ id }) => ({
type: 'Download' as const,
id,
})),
'Download',
]
: ['Download'],
}), }),
addDownload: build.mutation< getDownload: build.query<DownloadJob, string>({
DownloadJob, query: (id) => `/downloads/${id}`,
{ transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
url: string; providesTags: (_r, _e, id) => [{ type: 'Download', id }],
metadata?: { title?: string; artist?: string; album?: string }; }),
} /** Request a download of a discovered item (§A4 "Download to library"). */
createDownload: build.mutation<
DownloadRequestResult,
{ source: string; sourceId: string; query?: string }
>({ >({
query: (body) => ({ url: '/downloads', method: 'POST', body }), query: (body) => ({
invalidatesTags: ['Download'], url: '/downloads',
method: 'POST',
body: {
source: body.source,
source_id: body.sourceId,
query: body.query,
},
}),
transformResponse: (raw: RawCreateResponse): DownloadRequestResult => ({
alreadyInLibrary: raw.already_in_library,
trackId: raw.track_id ?? undefined,
job: raw.job ? toDownloadJob(raw.job) : undefined,
}),
// A completed dedup can surface an existing library track; refresh both.
invalidatesTags: ['Download', 'Track'],
}), }),
cancelDownload: build.mutation<void, string>({ cancelDownload: build.mutation<void, string>({
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }), query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
@@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({
}), }),
retryDownload: build.mutation<DownloadJob, string>({ retryDownload: build.mutation<DownloadJob, string>({
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }), query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
invalidatesTags: ['Download'], transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
invalidatesTags: (_r, _e, id) => [{ type: 'Download', id }, 'Download'],
}), }),
}), }),
overrideExisting: false, overrideExisting: false,
@@ -40,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
export const { export const {
useGetDownloadsQuery, useGetDownloadsQuery,
useAddDownloadMutation, useGetDownloadQuery,
useCreateDownloadMutation,
useCancelDownloadMutation, useCancelDownloadMutation,
useRetryDownloadMutation, useRetryDownloadMutation,
} = downloadsApi; } = downloadsApi;
+43
View File
@@ -2,10 +2,12 @@ import { api } from '../index';
import { import {
toAlbum, toAlbum,
toArtist, toArtist,
toMetadataMatch,
toPage, toPage,
toTrack, toTrack,
type RawAlbum, type RawAlbum,
type RawArtist, type RawArtist,
type RawMetadataMatch,
type RawPaged, type RawPaged,
type RawTrack, type RawTrack,
} from '../mappers'; } from '../mappers';
@@ -13,6 +15,8 @@ import type {
Track, Track,
Album, Album,
Artist, Artist,
MetadataEdit,
MetadataMatch,
PaginatedResponse, PaginatedResponse,
LibraryFilters, LibraryFilters,
} from '../types'; } from '../types';
@@ -38,6 +42,7 @@ function trackParams(f: LibraryFilters) {
q: f.search, q: f.search,
artist_id: f.artistId, artist_id: f.artistId,
album_id: f.albumId, album_id: f.albumId,
source: f.source,
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined, sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
order: f.sortOrder, order: f.sortOrder,
...paging(f.page, f.pageSize), ...paging(f.page, f.pageSize),
@@ -161,6 +166,41 @@ export const libraryApi = api.injectEndpoints({
}), }),
providesTags: ['Track', 'Album', 'Artist'], providesTags: ['Track', 'Album', 'Artist'],
}), }),
getMetadataMatches: build.query<MetadataMatch[], string>({
query: (trackId) => `/tracks/${trackId}/metadata/matches`,
transformResponse: (raw: { items: RawMetadataMatch[] }) =>
raw.items.map(toMetadataMatch),
}),
applyMetadata: build.mutation<
Track,
{ trackId: string; edit: MetadataEdit }
>({
query: ({ trackId, edit }) => ({
url: `/tracks/${trackId}/metadata`,
method: 'PUT',
body: {
title: edit.title,
artist_name: edit.artistName,
album_title: edit.albumTitle,
year: edit.year,
genre: edit.genre,
track_number: edit.trackNumber,
},
}),
transformResponse: (raw: RawTrack) => toTrack(raw),
invalidatesTags: (_r, _e, { trackId }) => [
{ type: 'Track', id: trackId },
'Album',
'Artist',
],
}),
enrichTrack: build.mutation<{ track_id: string; job_id: string }, string>({
query: (trackId) => ({
url: `/tracks/${trackId}/metadata/enrich`,
method: 'POST',
}),
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
}),
}), }),
overrideExisting: false, overrideExisting: false,
}); });
@@ -176,4 +216,7 @@ export const {
useGetArtistAlbumsQuery, useGetArtistAlbumsQuery,
useGetArtistTracksQuery, useGetArtistTracksQuery,
useSearchLibraryQuery, useSearchLibraryQuery,
useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation,
useEnrichTrackMutation,
} = libraryApi; } = libraryApi;
+48
View File
@@ -0,0 +1,48 @@
import { api } from '../index';
import {
toSearchResult,
toSourceInfo,
type RawSearchResult,
type RawSourceInfo,
} from '../mappers';
import type { ExternalSearchResult, SourceInfo } from '../types';
interface RawSearchResponse {
results: RawSearchResult[];
searched_sources: string[];
}
interface SearchResponse {
results: ExternalSearchResult[];
/** Names of the sources actually queried (available ones). */
searchedSources: string[];
}
const mapSearch = (raw: RawSearchResponse): SearchResponse => ({
results: raw.results.map(toSearchResult),
searchedSources: raw.searched_sources,
});
export const searchApi = api.injectEndpoints({
endpoints: (build) => ({
/** Registered source backends (for the §A4 source picker). */
getSources: build.query<SourceInfo[], void>({
query: () => '/sources',
transformResponse: (raw: RawSourceInfo[]) => raw.map(toSourceInfo),
}),
/** Search across every available fetch source (no `source` → aggregate). */
searchExternal: build.query<
SearchResponse,
{ q: string; source?: string; limit?: number }
>({
query: ({ q, source, limit }) =>
source
? { url: `/sources/${source}/search`, params: { q, limit } }
: { url: '/search', params: { q, limit } },
transformResponse: mapSearch,
}),
}),
overrideExisting: false,
});
export const { useGetSourcesQuery, useLazySearchExternalQuery } = searchApi;
+6 -7
View File
@@ -1,17 +1,16 @@
import { api } from '../index'; import { api } from '../index';
import { toStorageStats, type RawStorageStats } from '../mappers';
import type { StorageStats } from '../types'; import type { StorageStats } from '../types';
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body / // `GET /storage` returns library + disk statistics (§A6). The maintenance
// no schema), and the real paths differ from these placeholders (`GET /storage`, // routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`, // `POST /storage/cleanup`) are still backend stubs and unused by the UI.
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
// `mappers.ts`) once the backend defines the storage response shapes; until then
// these are provisional and unused by the UI.
export const storageApi = api.injectEndpoints({ export const storageApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({ getStorageStats: build.query<StorageStats, void>({
query: () => '/storage/stats', query: () => '/storage',
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
providesTags: ['Storage'], providesTags: ['Storage'],
}), }),
scanStorage: build.mutation<{ jobId: string }, void>({ scanStorage: build.mutation<{ jobId: string }, void>({
+15
View File
@@ -32,3 +32,18 @@ export function getTrackCoverUrl(
const base = getApiBaseUrl(); const base = getApiBaseUrl();
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`; 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',
+163
View File
@@ -14,9 +14,15 @@
import type { import type {
Album, Album,
Artist, Artist,
DownloadJob,
DownloadStatus,
ExternalSearchResult,
MetadataMatch,
MetadataStatus, MetadataStatus,
PaginatedResponse, PaginatedResponse,
Playlist, Playlist,
SourceInfo,
StorageStats,
Track, Track,
User, User,
} from './types'; } from './types';
@@ -63,6 +69,9 @@ export interface RawTrack {
duration_seconds: number | null; duration_seconds: number | null;
file_format: string; file_format: string;
file_size: number; file_size: number;
genre: string | null;
year: number | null;
track_number: number | null;
metadata_status: string; metadata_status: string;
metadata_error: string | null; metadata_error: string | null;
enriched_at: string | null; enriched_at: string | null;
@@ -71,6 +80,18 @@ export interface RawTrack {
created_at: string; created_at: string;
} }
/** One AcoustID candidate, as returned by `GET /tracks/{id}/metadata/matches`. */
export interface RawMetadataMatch {
acoustid: string;
score: number;
recording_mbid: string | null;
release_group_mbid: string | null;
title: string | null;
artist: string | null;
album: string | null;
year: number | null;
}
export interface RawAlbum { export interface RawAlbum {
id: string; id: string;
title: string; title: string;
@@ -78,6 +99,7 @@ export interface RawAlbum {
artist_name: string; artist_name: string;
year: number | null; year: number | null;
track_count: number; track_count: number;
has_cover: boolean;
created_at: string; created_at: string;
} }
@@ -127,9 +149,26 @@ export const toTrack = (r: RawTrack): Track => ({
availability: 'server', availability: 'server',
metadataStatus: toMetadataStatus(r.metadata_status), metadataStatus: toMetadataStatus(r.metadata_status),
metadataError: r.metadata_error ?? undefined, metadataError: r.metadata_error ?? undefined,
genre: r.genre ?? undefined,
year: r.year ?? undefined,
trackNumber: r.track_number ?? undefined,
liked: false, liked: false,
format: r.file_format, format: r.file_format,
fileSize: r.file_size, fileSize: r.file_size,
source: r.source,
createdAt: r.created_at,
enrichedAt: r.enriched_at ?? undefined,
});
export const toMetadataMatch = (r: RawMetadataMatch): MetadataMatch => ({
acoustid: r.acoustid,
score: r.score,
recordingMbid: r.recording_mbid ?? undefined,
releaseGroupMbid: r.release_group_mbid ?? undefined,
title: r.title ?? undefined,
artist: r.artist ?? undefined,
album: r.album ?? undefined,
year: r.year ?? undefined,
}); });
export const toAlbum = (r: RawAlbum): Album => ({ export const toAlbum = (r: RawAlbum): Album => ({
@@ -137,7 +176,10 @@ export const toAlbum = (r: RawAlbum): Album => ({
title: r.title, title: r.title,
artistId: r.artist_id, artistId: r.artist_id,
artistName: r.artist_name, artistName: r.artist_name,
// The album record carries no cover *URL*; `hasCover` says one exists, and the
// URL (which needs `?token=`) is built in components via `getAlbumCoverUrl`.
artUrl: undefined, artUrl: undefined,
hasCover: r.has_cover,
year: r.year ?? undefined, year: r.year ?? undefined,
trackCount: r.track_count, trackCount: r.track_count,
// AlbumOut has no aggregate duration; computed client-side from tracks when // AlbumOut has no aggregate duration; computed client-side from tracks when
@@ -167,6 +209,127 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
updatedAt: r.created_at, updatedAt: r.created_at,
}); });
interface RawStorageStats {
total_tracks: number;
total_artists: number;
total_albums: number;
total_size: number;
total_duration_seconds: number;
largest_track_size: number;
earliest_added: string | null;
latest_added: string | null;
by_format: { file_format: string; track_count: number; total_size: number }[];
by_metadata_status: Record<string, number>;
by_source: Record<string, number>;
top_genres: { genre: string; track_count: number }[];
disk: { total: number; used: number; free: number } | null;
}
export type { RawStorageStats };
export const toStorageStats = (r: RawStorageStats): StorageStats => ({
totalTracks: r.total_tracks,
totalArtists: r.total_artists,
totalAlbums: r.total_albums,
totalSize: r.total_size,
totalDurationSeconds: r.total_duration_seconds,
largestTrackSize: r.largest_track_size,
earliestAdded: r.earliest_added ?? undefined,
latestAdded: r.latest_added ?? undefined,
byFormat: r.by_format.map((f) => ({
fileFormat: f.file_format,
trackCount: f.track_count,
totalSize: f.total_size,
})),
byMetadataStatus: r.by_metadata_status,
bySource: r.by_source,
topGenres: r.top_genres.map((g) => ({
genre: g.genre,
trackCount: g.track_count,
})),
disk: r.disk ?? undefined,
});
// ---- downloads + external search ----
const DOWNLOAD_STATUSES: readonly DownloadStatus[] = [
'queued',
'downloading',
'enriching',
'done',
'failed',
];
const toDownloadStatus = (raw: string): DownloadStatus =>
(DOWNLOAD_STATUSES as readonly string[]).includes(raw)
? (raw as DownloadStatus)
: 'queued';
export interface RawDownloadJob {
id: string;
source: string;
source_id: string | null;
query: string | null;
status: string;
progress: number;
error_message: string | null;
retry_count: number;
track_id: string | null;
created_at: string;
updated_at: string;
}
export const toDownloadJob = (r: RawDownloadJob): DownloadJob => ({
id: r.id,
source: r.source,
sourceId: r.source_id ?? undefined,
query: r.query ?? undefined,
status: toDownloadStatus(r.status),
progress: r.progress,
errorMessage: r.error_message ?? undefined,
trackId: r.track_id ?? undefined,
retryCount: r.retry_count,
createdAt: r.created_at,
updatedAt: r.updated_at,
});
export interface RawSearchResult {
source: string;
source_id: string;
title: string;
artist: string | null;
album: string | null;
duration_seconds: number | null;
thumbnail_url: string | null;
}
export const toSearchResult = (r: RawSearchResult): ExternalSearchResult => ({
source: r.source,
sourceId: r.source_id,
title: r.title,
artist: r.artist ?? undefined,
album: r.album ?? undefined,
durationMs:
r.duration_seconds != null ? r.duration_seconds * 1000 : undefined,
thumbnailUrl: r.thumbnail_url ?? undefined,
});
export interface RawSourceInfo {
name: string;
label: string;
kind: string;
available: boolean;
}
export const toSourceInfo = (r: RawSourceInfo): SourceInfo => ({
name: r.name,
label: r.label,
// Backend kinds are `indexable` | `fetch`; default unknowns to indexable
// (the conservative, non-searchable bucket).
kind: r.kind === 'fetch' ? 'fetch' : 'indexable',
available: r.available,
});
/** /**
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's * Translate the backend's `{items,total,limit,offset}` envelope into the UI's
* `{items,total,page,pageSize,hasMore}`, mapping each element. * `{items,total,page,pageSize,hasMore}`, mapping each element.
+113 -10
View File
@@ -30,6 +30,12 @@ export interface Track {
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 {
@@ -38,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;
@@ -70,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 {
@@ -143,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;
@@ -155,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>
@@ -1,5 +1,6 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk'; import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import type { MetadataStatus } from '../../api/types'; import type { MetadataStatus } from '../../api/types';
interface Props { interface Props {
@@ -9,6 +10,9 @@ interface Props {
/** When true, render nothing for the normal `enriched` state (keeps dense /** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */ * track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean; 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'; type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
@@ -20,6 +24,19 @@ const VARIANT: Record<MetadataStatus, Variant> = {
manual: 'outline', 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). * Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover. * `pending` carries a spinner; `failed` exposes the backend reason on hover.
@@ -28,6 +45,7 @@ export function MetadataStatusBadge({
status, status,
error, error,
hideWhenEnriched = true, hideWhenEnriched = true,
iconOnly,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null; if (status === 'enriched' && hideWhenEnriched) return null;
@@ -36,6 +54,23 @@ export function MetadataStatusBadge({
const tooltip = const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`); 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 ( return (
<Tooltip content={tooltip}> <Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}> <Badge variant={VARIANT[status]} dot={status !== 'pending'}>
+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>
);
}
+96 -28
View File
@@ -1,10 +1,15 @@
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 { 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, getTrackCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
@@ -12,6 +17,10 @@ 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;
@@ -21,10 +30,12 @@ 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);
@@ -36,46 +47,102 @@ export function TrackRow({
getCoverUrl(track.albumArtUrl) ?? getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined); (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={{
@@ -105,8 +172,9 @@ export function TrackRow({
<MetadataStatusBadge <MetadataStatusBadge
status={track.metadataStatus} status={track.metadataStatus}
error={track.metadataError} error={track.metadataError}
iconOnly
/> />
<AvailabilityBadge availability={track.availability} /> <AvailabilityBadge availability={displayAvailability} iconOnly />
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span <span
+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>
);
} }
+37 -12
View File
@@ -2,7 +2,15 @@ import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge, Dialog } from '@olly/modern-sk'; import {
Card,
TextField,
Button,
Callout,
Badge,
Dialog,
IconButton,
} from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon'; import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useConnectionStatus } from '../../hooks/useConnectionStatus'; import { useConnectionStatus } from '../../hooks/useConnectionStatus';
@@ -139,7 +147,11 @@ function InstanceRow({
} }
footer={ footer={
<div <div
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }} style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
}}
> >
<Button <Button
variant="ghost" variant="ghost"
@@ -187,8 +199,9 @@ export function ConnectPage() {
getActiveInstanceId(), getActiveInstanceId(),
); );
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null; const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
const [instanceAddShown, setInstanceAddShown] = useState(false);
const [addUrl, setAddUrl] = useState('https://'); const [addUrl, setAddUrl] = useState('');
const [mode, setMode] = useState<Mode>('login'); const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -333,15 +346,27 @@ export function ConnectPage() {
marginTop: instances.length > 0 ? '0.5rem' : 0, marginTop: instances.length > 0 ? '0.5rem' : 0,
}} }}
> >
<TextField {instanceAddShown ? (
value={addUrl} <>
onChange={(e) => setAddUrl(e.target.value)} <TextField
placeholder={t('connect.domains.addPlaceholder')} value={addUrl}
style={{ flex: 1 }} onChange={(e) => setAddUrl(e.target.value)}
/> placeholder={t('connect.domains.addPlaceholder')}
<Button type="submit" variant="primary"> style={{ flex: 1 }}
<Icon name="plus" /> {t('connect.domains.addButton')} />
</Button> <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> </form>
</div> </div>
</Card> </Card>
@@ -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,
}}
/>
);
}
+65 -6
View File
@@ -6,8 +6,23 @@ import {
buildUploadFormData, buildUploadFormData,
useUploadTrackMutation, useUploadTrackMutation,
} from '../../api/endpoints/upload'; } from '../../api/endpoints/upload';
import { useGetTrackQuery } from '../../api/endpoints/library'; import {
useGetTrackQuery,
useGetTracksQuery,
} from '../../api/endpoints/library';
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge'; 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';
@@ -25,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';
@@ -40,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 });
@@ -163,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">
@@ -183,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={{
@@ -228,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>
+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());
+29 -1
View File
@@ -1,7 +1,12 @@
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 };
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */ /** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) { export function useConnectionStatus(baseUrl?: string) {
@@ -36,3 +41,26 @@ export function useConnectionStatus(baseUrl?: string) {
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,
};
}
+205 -6
View File
@@ -97,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',
@@ -107,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',
@@ -120,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',
@@ -149,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: {
@@ -156,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',
@@ -219,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',
@@ -230,6 +354,48 @@ 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: { metadata: {
status: { status: {
pending: 'Enriching…', pending: 'Enriching…',
@@ -244,6 +410,39 @@ const en = {
manual: 'Edited manually — not auto-updated', 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;
+205 -6
View File
@@ -99,6 +99,13 @@ const ru: Translations = {
artistRow: { artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков', meta: '{{albumCount}} альб. · {{trackCount}} треков',
}, },
offline: {
banner:
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
emptyTitle: 'Офлайн ничего нет',
emptyDesc:
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
},
}, },
album: { album: {
type: 'Альбом', type: 'Альбом',
@@ -109,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: 'Плейлист',
@@ -122,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: '∞ микс',
@@ -151,6 +177,13 @@ const ru: Translations = {
loadingMore: 'Загрузка радио…', loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения', doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди', removeFromQueue: 'Убрать из очереди',
menu: {
options: 'Параметры трека',
playNow: 'Воспроизвести сейчас',
moveNext: 'Сделать следующим',
info: 'Информация о треке',
remove: 'Убрать из очереди',
},
}, },
track: { track: {
menu: { menu: {
@@ -158,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: 'Администрирование',
@@ -221,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 · метаданные в ожидании',
@@ -232,6 +355,49 @@ 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: { metadata: {
status: { status: {
pending: 'Обработка…', pending: 'Обработка…',
@@ -246,6 +412,39 @@ const ru: Translations = {
manual: 'Изменено вручную — не обновляется автоматически', 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,
}; };
} }
+5 -1
View File
@@ -32,7 +32,11 @@ function snapshot(apiState: ApiState): RehydrateApiPayload {
} }
// Carry `provided` along so RTKQ can re-register invalidation tags for the // Carry `provided` along so RTKQ can re-register invalidation tags for the
// restored entries; it is also required structurally (see RehydrateApiPayload). // restored entries; it is also required structurally (see RehydrateApiPayload).
return { queries, mutations: {}, provided: apiState.provided ?? EMPTY_PROVIDED }; return {
queries,
mutations: {},
provided: apiState.provided ?? EMPTY_PROVIDED,
};
} }
function load(): RehydrateApiPayload | null { function load(): RehydrateApiPayload | null {
+97
View File
@@ -0,0 +1,97 @@
/*
* Offline library composition. When the active backend is unreachable, a single
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
* can't rely on it to render the library. Instead we compose the "locally
* available" library from *every* fulfilled entry in the RTK Query cache —
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
* this session. This is read-only derived data, not a server-data slice copy:
* it reads straight from the RTKQ cache the architecture already owns.
*/
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../index';
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
interface CacheEntry {
status: string;
endpointName?: string;
data?: unknown;
}
const selectQueries = (state: RootState): Record<string, unknown> =>
state.api.queries;
function fulfilled(queries: Record<string, unknown>): CacheEntry[] {
const out: CacheEntry[] = [];
for (const entry of Object.values(queries)) {
const e = entry as CacheEntry | undefined;
if (e && e.status === 'fulfilled' && e.data != null) out.push(e);
}
return out;
}
/** Every track known locally, deduped by id (last write wins). */
export const selectLocalTracks = createSelector(
selectQueries,
(queries): Track[] => {
const byId = new Map<string, Track>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getTracks':
for (const t of (e.data as PaginatedResponse<Track>).items)
byId.set(t.id, t);
break;
case 'getAlbumTracks':
case 'getArtistTracks':
for (const t of e.data as Track[]) byId.set(t.id, t);
break;
case 'getTrack':
byId.set((e.data as Track).id, e.data as Track);
break;
}
}
return [...byId.values()];
},
);
/** Every album known locally, deduped by id. */
export const selectLocalAlbums = createSelector(
selectQueries,
(queries): Album[] => {
const byId = new Map<string, Album>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getAlbums':
for (const a of (e.data as PaginatedResponse<Album>).items)
byId.set(a.id, a);
break;
case 'getArtistAlbums':
for (const a of e.data as Album[]) byId.set(a.id, a);
break;
case 'getAlbum':
byId.set((e.data as Album).id, e.data as Album);
break;
}
}
return [...byId.values()];
},
);
/** Every artist known locally, deduped by id. */
export const selectLocalArtists = createSelector(
selectQueries,
(queries): Artist[] => {
const byId = new Map<string, Artist>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getArtists':
for (const a of (e.data as PaginatedResponse<Artist>).items)
byId.set(a.id, a);
break;
case 'getArtist':
byId.set((e.data as Artist).id, e.data as Artist);
break;
}
}
return [...byId.values()];
},
);
+28
View File
@@ -0,0 +1,28 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type ConnectionStatus =
| 'connected'
| 'connecting'
| 'disconnected'
| 'error';
export interface ConnectionState {
status: ConnectionStatus;
}
export const connectionInitialState: ConnectionState = {
status: 'connecting',
};
export const connectionSlice = createSlice({
name: 'connection',
initialState: connectionInitialState,
reducers: {
setConnectionStatus(state, action: PayloadAction<ConnectionStatus>) {
state.status = action.payload;
},
},
});
export const { setConnectionStatus } = connectionSlice.actions;
export default connectionSlice.reducer;
-20
View File
@@ -1,7 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type RepeatMode = 'none' | 'one' | 'all';
export interface PlayerState { export interface PlayerState {
currentTrackId: string | null; currentTrackId: string | null;
isPlaying: boolean; isPlaying: boolean;
@@ -9,9 +7,6 @@ export interface PlayerState {
duration: number; duration: number;
volume: number; volume: number;
muted: boolean; muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isNowPlayingOpen: boolean;
isQueueOpen: boolean; isQueueOpen: boolean;
} }
@@ -22,9 +17,6 @@ export const playerInitialState: PlayerState = {
duration: 0, duration: 0,
volume: 0.78, volume: 0.78,
muted: false, muted: false,
repeat: 'none',
shuffle: false,
isNowPlayingOpen: false,
isQueueOpen: false, isQueueOpen: false,
}; };
@@ -60,15 +52,6 @@ export const playerSlice = createSlice({
toggleMute(state) { toggleMute(state) {
state.muted = !state.muted; state.muted = !state.muted;
}, },
setRepeat(state, action: PayloadAction<RepeatMode>) {
state.repeat = action.payload;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleNowPlaying(state) {
state.isNowPlayingOpen = !state.isNowPlayingOpen;
},
toggleQueue(state) { toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen; state.isQueueOpen = !state.isQueueOpen;
}, },
@@ -84,9 +67,6 @@ export const {
setDuration, setDuration,
setVolume, setVolume,
toggleMute, toggleMute,
setRepeat,
toggleShuffle,
toggleNowPlaying,
toggleQueue, toggleQueue,
} = playerSlice.actions; } = playerSlice.actions;
export default playerSlice.reducer; export default playerSlice.reducer;
+27 -1
View File
@@ -23,6 +23,8 @@ export interface QueueState {
source: QueueSource; source: QueueSource;
sourceId: string | null; sourceId: string | null;
sourceName: string | null; sourceName: string | null;
shuffle: boolean;
loop: boolean;
} }
export const queueInitialState: QueueState = { export const queueInitialState: QueueState = {
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
source: 'manual', source: 'manual',
sourceId: null, sourceId: null,
sourceName: null, sourceName: null,
shuffle: false,
loop: false,
}; };
export const queueSlice = createSlice({ export const queueSlice = createSlice({
@@ -59,6 +63,11 @@ export const queueSlice = createSlice({
addNextInQueue(state, action: PayloadAction<QueueEntry>) { addNextInQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.splice(state.currentIndex + 1, 0, action.payload); state.entries.splice(state.currentIndex + 1, 0, action.payload);
}, },
playNow(state, action: PayloadAction<QueueEntry>) {
const insertAt = state.currentIndex + 1;
state.entries.splice(insertAt, 0, action.payload);
state.currentIndex = insertAt;
},
removeFromQueue(state, action: PayloadAction<number>) { removeFromQueue(state, action: PayloadAction<number>) {
state.entries.splice(action.payload, 1); state.entries.splice(action.payload, 1);
if (action.payload < state.currentIndex) state.currentIndex--; if (action.payload < state.currentIndex) state.currentIndex--;
@@ -77,7 +86,15 @@ export const queueSlice = createSlice({
state.currentIndex = action.payload; state.currentIndex = action.payload;
}, },
nextTrack(state) { nextTrack(state) {
if (state.currentIndex < state.entries.length - 1) state.currentIndex++; if (state.shuffle && state.entries.length > 1) {
let next = state.currentIndex;
while (next === state.currentIndex) {
next = Math.floor(Math.random() * state.entries.length);
}
state.currentIndex = next;
} else if (state.currentIndex < state.entries.length - 1) {
state.currentIndex++;
}
}, },
prevTrack(state) { prevTrack(state) {
if (state.currentIndex > 0) state.currentIndex--; if (state.currentIndex > 0) state.currentIndex--;
@@ -86,6 +103,12 @@ export const queueSlice = createSlice({
state.entries = []; state.entries = [];
state.currentIndex = -1; state.currentIndex = -1;
}, },
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleLoop(state) {
state.loop = !state.loop;
},
}, },
}); });
@@ -93,11 +116,14 @@ export const {
setQueue, setQueue,
addToQueue, addToQueue,
addNextInQueue, addNextInQueue,
playNow,
removeFromQueue, removeFromQueue,
moveInQueue, moveInQueue,
goToIndex, goToIndex,
nextTrack, nextTrack,
prevTrack, prevTrack,
clearQueue, clearQueue,
toggleShuffle,
toggleLoop,
} = queueSlice.actions; } = queueSlice.actions;
export default queueSlice.reducer; export default queueSlice.reducer;
+11
View File
@@ -4,12 +4,15 @@ interface UiState {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
activeModal: string | null; activeModal: string | null;
activeTrackContextMenuId: string | null; activeTrackContextMenuId: string | null;
/** Track whose info drawer is open (rightmost drawer); null = closed. */
trackInfoId: string | null;
} }
const initialState: UiState = { const initialState: UiState = {
sidebarCollapsed: false, sidebarCollapsed: false,
activeModal: null, activeModal: null,
activeTrackContextMenuId: null, activeTrackContextMenuId: null,
trackInfoId: null,
}; };
export const uiSlice = createSlice({ export const uiSlice = createSlice({
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
setActiveContextMenu(state, action: PayloadAction<string | null>) { setActiveContextMenu(state, action: PayloadAction<string | null>) {
state.activeTrackContextMenuId = action.payload; state.activeTrackContextMenuId = action.payload;
}, },
openTrackInfo(state, action: PayloadAction<string>) {
state.trackInfoId = action.payload;
},
closeTrackInfo(state) {
state.trackInfoId = null;
},
}, },
}); });
@@ -40,5 +49,7 @@ export const {
openModal, openModal,
closeModal, closeModal,
setActiveContextMenu, setActiveContextMenu,
openTrackInfo,
closeTrackInfo,
} = uiSlice.actions; } = uiSlice.actions;
export default uiSlice.reducer; export default uiSlice.reducer;
+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']);
});
+7 -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;
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import {
} from '../public/sw-core.js'; } from '../public/sw-core.js';
test('trackIdFromUrl extracts the content id from a stream URL', () => { test('trackIdFromUrl extracts the content id from a stream URL', () => {
expect( expect(trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz')).toBe(
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'), 'abc123',
).toBe('abc123'); );
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull(); expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
}); });