Compare commits

...

18 Commits

Author SHA1 Message Date
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
36 changed files with 2503 additions and 320 deletions
+70 -1
View File
@@ -8,6 +8,9 @@
"name": "mcma-webui",
"version": "1.0.0",
"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",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
@@ -16,7 +19,8 @@
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
"react-router": "^7.16.0",
"use-debounce": "^10.1.1"
},
"devDependencies": {
"@rsbuild/core": "^2.0.7",
@@ -551,6 +555,59 @@
"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": {
"version": "1.10.0",
"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": {
"version": "1.1.3",
"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"
},
"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",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
@@ -21,7 +24,8 @@
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
"react-router": "^7.16.0",
"use-debounce": "^10.1.1"
},
"devDependencies": {
"@rsbuild/core": "^2.0.7",
+1
View File
@@ -42,6 +42,7 @@ function trackParams(f: LibraryFilters) {
q: f.search,
artist_id: f.artistId,
album_id: f.albumId,
source: f.source,
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
order: f.sortOrder,
...paging(f.page, f.pageSize),
+6 -7
View File
@@ -1,17 +1,16 @@
import { api } from '../index';
import { toStorageStats, type RawStorageStats } from '../mappers';
import type { StorageStats } from '../types';
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
// no schema), and the real paths differ from these placeholders (`GET /storage`,
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `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.
// `GET /storage` returns library + disk statistics (§A6). The maintenance
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
export const storageApi = api.injectEndpoints({
endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({
query: () => '/storage/stats',
query: () => '/storage',
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
providesTags: ['Storage'],
}),
scanStorage: build.mutation<{ jobId: string }, void>({
+15
View File
@@ -32,3 +32,18 @@ export function getTrackCoverUrl(
const base = getApiBaseUrl();
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
}
/**
* Cover image URL for an album, served by `GET /albums/{id}/cover`. Same
* `?token=` rationale as the track cover. Returns undefined when the album has
* no cover (so callers fall back to generated tile art).
*/
export function getAlbumCoverUrl(
albumId: string,
token: string,
hasCover: boolean,
): string | undefined {
if (!hasCover) return undefined;
const base = getApiBaseUrl();
return `${base}/albums/${albumId}/cover?token=${encodeURIComponent(token)}`;
}
+46
View File
@@ -18,6 +18,7 @@ import type {
MetadataStatus,
PaginatedResponse,
Playlist,
StorageStats,
Track,
User,
} from './types';
@@ -94,6 +95,7 @@ export interface RawAlbum {
artist_name: string;
year: number | null;
track_count: number;
has_cover: boolean;
created_at: string;
}
@@ -170,7 +172,10 @@ export const toAlbum = (r: RawAlbum): Album => ({
title: r.title,
artistId: r.artist_id,
artistName: r.artist_name,
// The album record carries no cover *URL*; `hasCover` says one exists, and the
// URL (which needs `?token=`) is built in components via `getAlbumCoverUrl`.
artUrl: undefined,
hasCover: r.has_cover,
year: r.year ?? undefined,
trackCount: r.track_count,
// AlbumOut has no aggregate duration; computed client-side from tracks when
@@ -200,6 +205,47 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
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,
});
/**
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
* `{items,total,page,pageSize,hasMore}`, mapping each element.
+37 -5
View File
@@ -44,6 +44,8 @@ export interface Album {
artistId: string;
artistName: string;
artUrl?: string;
/** Whether the album has cover art served by `GET /albums/{id}/cover`. */
hasCover: boolean;
year?: number;
trackCount: number;
totalDurationMs: number;
@@ -96,12 +98,40 @@ export interface UploadResponse {
already_exists: boolean;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
export interface StorageFormatBreakdown {
fileFormat: string;
trackCount: number;
albumCount: number;
artistCount: number;
totalSize: 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 {
@@ -149,6 +179,8 @@ export interface LibraryFilters {
genre?: string;
artistId?: string;
albumId?: string;
/** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */
source?: string;
liked?: boolean;
page?: number;
pageSize?: number;
+2
View File
@@ -15,6 +15,7 @@ import {
ArrowsClockwise,
CheckCircle,
Cloud,
CloudSlash,
DotsSixVertical,
GearSix,
HardDrives,
@@ -75,6 +76,7 @@ const ICONS = {
'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX,
cloud: Cloud,
'cloud-slash': CloudSlash,
'check-circle': CheckCircle,
'warning-circle': WarningCircle,
'sign-out': SignOut,
+39
View File
@@ -0,0 +1,39 @@
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 -2
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
import { getActiveInstance } from '../../config/instances';
@@ -41,7 +41,7 @@ export function Sidebar() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatus();
const status = useConnectionStatusSync();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
+2 -28
View File
@@ -8,8 +8,6 @@ import {
resume,
toggleMute,
setVolume,
toggleShuffle,
setRepeat,
toggleQueue,
} from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
@@ -44,6 +42,7 @@ export function PersistentPlayer() {
: undefined);
const seedLabel = current?.albumTitle ?? current?.title ?? '';
const onStream = !cached;
const formatLabel = current?.format?.toUpperCase();
return (
<div className="player">
@@ -65,20 +64,13 @@ export function PersistentPlayer() {
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? t('player.streaming') : t('player.local')}
{formatLabel && ` · ${formatLabel}`}
</div>
</div>
</div>
<div className="pl-center">
<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
type="button"
className="pl-tbtn"
@@ -105,24 +97,6 @@ export function PersistentPlayer() {
>
<Icon name="skip-forward" fill />
</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 className="pl-seek">
<span className="pl-time">
+164 -62
View File
@@ -1,33 +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 {
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 { ArtTile } from '../common/ArtTile';
import { Marquee } from '../common/Marquee';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import {
goToIndex,
removeFromQueue,
moveInQueue,
clearQueue,
toggleShuffle,
toggleLoop,
type QueueEntry,
} from '../../store/slices/queue';
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() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
const nowEntry =
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const now = useResolvedQueueEntry(nowEntry);
const upNext = queue.entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex);
const hasEntries = queue.entries.length > 0;
const isRadio = queue.source === 'radio';
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 (
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner">
@@ -35,6 +74,22 @@ export function QueuePanel() {
<div className="row">
<h3>{t('queue.title')}</h3>
<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
type="button"
className="iconbtn sm"
@@ -68,34 +123,8 @@ export function QueuePanel() {
</div>
<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>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(openTrackInfo(now.trackId))}
title={t('trackInfo.open')}
>
<Icon name="info" />
</button>
</div>
{isRadio && (
<div className="qd-radio">
<div className="row">
@@ -133,18 +162,36 @@ export function QueuePanel() {
>
{t('queue.nextUp')}
</span>
{upNext.length === 0 ? (
<div className="qd-empty">{t('queue.nothingNext')}</div>
) : (
upNext.map(({ entry, index }) => (
<QueueRow
key={`${entry.trackId}-${index}`}
entry={entry}
onPlay={() => dispatch(goToIndex(index))}
onRemove={() => dispatch(removeFromQueue(index))}
/>
))
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.entries.map((_, index) => String(index))}
strategy={verticalListSortingStrategy}
>
{queue.entries.map((entry, index) => (
<QueueRow
key={`${entry.trackId}-${index}`}
id={String(index)}
entry={entry}
isCurrent={index === queue.currentIndex}
isPlaying={isPlaying}
onPlay={() => dispatch(goToIndex(index))}
onMoveNext={() =>
dispatch(
moveInQueue({
from: index,
to: queue.currentIndex + 1,
}),
)
}
onRemove={() => dispatch(removeFromQueue(index))}
/>
))}
</SortableContext>
</DndContext>
{isRadio && (
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
@@ -159,43 +206,98 @@ export function QueuePanel() {
);
}
/** An "up next" row, resolving its display fields against the live Track cache
* (same read-through as the now-playing entry) so enrichment updates show. */
/** 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
className="qrow"
ref={setNodeRef}
style={style}
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')}
>
<span className="grip">
<span className="grip" {...attributes} {...listeners}>
<Icon name="dots-six-vertical" />
</span>
<ArtTile seed={albumTitle} size={36} label={albumTitle} />
<div className="qt">
<div className="t">{resolved?.title ?? entry.title}</div>
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
<div className="qart">
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
{isCurrent && (
<div className="cover-playing">
<PlayingIndicator animate={isPlaying} />
</div>
)}
</div>
<button
type="button"
className="iconbtn sm"
onClick={onRemove}
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
<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 { Icon, type IconName } from '../common/Icon';
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 {
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<
TrackAvailability,
DisplayAvailability,
{
label: string;
variant: 'lime' | 'ember' | 'neutral' | 'outline';
variant: Variant;
icon: IconName;
spin?: boolean;
tooltip: string;
}
> = {
server: {
label: 'On server',
variant: 'lime',
icon: 'cloud',
tooltip: 'File available on server',
},
local: {
label: 'Local',
variant: 'lime',
icon: 'hard-drives',
tooltip: 'Cached on this device — playable offline',
},
downloading: {
label: 'Downloading',
variant: 'neutral',
icon: 'arrows-clockwise',
spin: true,
tooltip: 'Currently downloading',
},
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
error: {
label: 'Error',
variant: 'ember',
icon: 'warning-circle',
tooltip: 'Download failed',
},
missing: {
label: 'Missing',
variant: 'outline',
icon: 'cloud-slash',
tooltip: 'File not found on server',
},
};
export function AvailabilityBadge({ availability }: Props) {
export function AvailabilityBadge({ availability, iconOnly }: Props) {
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 (
<Tooltip content={cfg.tooltip}>
<Badge variant={cfg.variant} dot>
@@ -1,5 +1,6 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import type { MetadataStatus } from '../../api/types';
interface Props {
@@ -9,6 +10,9 @@ interface Props {
/** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
}
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
@@ -20,6 +24,19 @@ const VARIANT: Record<MetadataStatus, Variant> = {
manual: 'outline',
};
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
const ICON: Record<Exclude<MetadataStatus, 'pending'>, IconName> = {
enriched: 'check-circle',
failed: 'warning-circle',
manual: 'push-pin',
};
/**
* Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
@@ -28,6 +45,7 @@ export function MetadataStatusBadge({
status,
error,
hideWhenEnriched = true,
iconOnly,
}: Props) {
const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null;
@@ -36,6 +54,23 @@ export function MetadataStatusBadge({
const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
if (iconOnly) {
return (
<Tooltip content={tooltip}>
<span style={{ display: 'inline-flex' }}>
{status === 'pending' ? (
<Spinner size="sm" />
) : (
<Icon
name={ICON[status]}
style={{ color: COLOR_VAR[VARIANT[status]], fontSize: 15 }}
/>
)}
</span>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
+96 -28
View File
@@ -1,10 +1,15 @@
import { Row } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { Icon } from '../common/Icon';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { formatDuration } from '../../lib/format';
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 { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
@@ -12,6 +17,10 @@ interface Props {
track: Track;
index?: number;
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;
onEditMetadata?: (track: Track) => void;
onDelete?: (track: Track) => void;
@@ -21,10 +30,12 @@ export function TrackRow({
track,
index,
showAlbum = false,
hideArt = false,
onAddToPlaylist,
onEditMetadata,
onDelete,
}: Props) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
@@ -36,46 +47,102 @@ export function TrackRow({
getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
// The backend reports `server`, but if it's unreachable and this track's
// audio is already in the offline cache, show "Local" instead.
const offline = useIsOffline();
const cached = useStreamCached(offline ? track.id : undefined);
const displayAvailability =
track.availability === 'server' && offline && cached
? 'local'
: track.availability;
const handlePlayNow = () => {
dispatch(
playNow({
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
}),
);
};
return (
<Row
selected={isActive}
onDoubleClick={() => dispatch(play(track.id))}
style={{
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',
alignItems: 'center',
padding: '0.375rem 0.75rem',
cursor: 'default',
}}
>
<span
style={{
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
{!hideArt && (
<span
style={{
width: 36,
height: 36,
borderRadius: 4,
background: 'var(--color-surface-3)',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textAlign: 'right',
}}
/>
>
{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={{
@@ -105,8 +172,9 @@ export function TrackRow({
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
iconOnly
/>
<AvailabilityBadge availability={track.availability} />
<AvailabilityBadge availability={displayAvailability} iconOnly />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span
+57 -21
View File
@@ -1,6 +1,6 @@
import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk';
import {
useGetAlbumQuery,
useGetAlbumTracksQuery,
@@ -9,29 +9,56 @@ import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
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 { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() {
const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const token = useAppSelector((s) => s.auth.accessToken);
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
if (albumQuery.isLoading || tracksQuery.isLoading) {
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
// Offline fallback: resolve the album + its tracks from the locally-cached
// library when the backend is unreachable (same approach as LibraryPage).
const offline = useIsOffline();
const localAlbums = useAppSelector(selectLocalAlbums);
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 (
<ErrorState
message={t('album.error')}
@@ -39,10 +66,13 @@ export function AlbumDetailPage() {
/>
);
}
const album = albumQuery.data;
const tracks = tracksQuery.data ?? [];
const artUrl = getCoverUrl(album?.artUrl);
// The album record itself carries no cover; fall back to a track's cover.
const coverTrack = tracks.find((t) => t.hasCover);
const artUrl =
getCoverUrl(album?.artUrl) ??
(token && coverTrack
? getTrackCoverUrl(coverTrack.id, token, true)
: undefined);
const handlePlayAll = () => {
if (!tracks.length || !album) return;
@@ -65,6 +95,11 @@ export function AlbumDetailPage() {
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',
@@ -161,16 +196,17 @@ export function AlbumDetailPage() {
</div>
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
<LoadingSkeleton rows={10} />
)}
{tracks.length === 0 && !offline && tracksQuery.isError && (
<ErrorState
message={t('album.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
{!tracksQuery.isLoading &&
!tracksQuery.isError &&
tracks.length === 0 && (
{tracks.length === 0 &&
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
<EmptyState
icon="♫"
title={t('album.empty.title')}
@@ -178,7 +214,7 @@ export function AlbumDetailPage() {
/>
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} />
<TrackRow key={track.id} track={track} index={i} hideArt />
))}
</ScrollArea>
</div>
+302 -3
View File
@@ -1,8 +1,307 @@
import { useParams, useNavigate } from 'react-router';
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() {
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>
);
}
+161 -70
View File
@@ -5,9 +5,10 @@ import {
Tabs,
TabsList,
TabsContent,
SearchField,
ScrollArea,
Card,
TextField,
Callout,
} from '@olly/modern-sk';
import {
useGetTracksQuery,
@@ -18,11 +19,32 @@ import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
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 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 { 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() {
const { t } = useTranslation();
@@ -30,10 +52,37 @@ export function LibraryPage() {
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300);
const tracksQuery = useGetTracksQuery(search ? { search } : undefined);
const albumsQuery = useGetAlbumsQuery(search ? { search } : undefined);
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
const tracksQuery = useGetTracksQuery(
debouncedSearch ? { search } : undefined,
);
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();
// 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[]) => {
dispatch(
@@ -68,15 +117,20 @@ export function LibraryPage() {
{t('library.title')}
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
<TextField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('library.searchPlaceholder')}
icon="⌕"
/>
</div>
</div>
{offline && (
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
<Callout variant="info">{t('library.offline.banner')}</Callout>
</div>
)}
<Tabs
value={tab}
onValueChange={setTab}
@@ -105,74 +159,84 @@ export function LibraryPage() {
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
{tracksQuery.isError && (
{!tracksToShow && tracksQuery.isLoading && (
<LoadingSkeleton rows={12} />
)}
{!tracksToShow && !offline && tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} />
)}
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
{tracksToShow && tracksToShow.length === 0 && (
<EmptyState
icon="♫"
title={t('library.empty.tracks.title')}
description={t('library.empty.tracks.description')}
title={t(
offline
? 'library.offline.emptyTitle'
: 'library.empty.tracks.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.tracks.description',
)}
/>
)}
{tracksQuery.data &&
tracksQuery.data.items.length > 0 &&
(() => {
const data = tracksQuery.data!;
return (
<div>
<div
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
borderBottom: '1px solid var(--color-border)',
}}
>
<button
onClick={() => handlePlayAll(data.items)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-accent)',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{t('library.playAll', { count: data.total })}
</button>
</div>
{data.items.map((track, i) => (
<TrackRow
key={track.id}
track={track}
index={i}
showAlbum
/>
))}
</div>
);
})()}
{tracksToShow && tracksToShow.length > 0 && (
<div>
<div
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
borderBottom: '1px solid var(--color-border)',
}}
>
<button
onClick={() => handlePlayAll(tracksToShow)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-accent)',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{t('library.playAll', { count: tracksToShow.length })}
</button>
</div>
{tracksToShow.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} showAlbum />
))}
</div>
)}
</ScrollArea>
</TabsContent>
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
{albumsQuery.isError && (
{!albumsToShow && albumsQuery.isLoading && (
<LoadingSkeleton rows={8} height={72} />
)}
{!albumsToShow && !offline && albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} />
)}
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
{albumsToShow && albumsToShow.length === 0 && (
<EmptyState
icon="💿"
title={t('library.empty.albums.title')}
description={t('library.empty.albums.description')}
title={t(
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
style={{
display: 'grid',
@@ -181,7 +245,7 @@ export function LibraryPage() {
padding: '1.25rem 1.5rem',
}}
>
{albumsQuery.data.items.map((album) => (
{albumsToShow.map((album) => (
<AlbumCard
key={album.id}
album={album}
@@ -195,21 +259,35 @@ export function LibraryPage() {
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
{artistsQuery.isError && (
{!artistsToShow && artistsQuery.isLoading && (
<LoadingSkeleton rows={8} />
)}
{!artistsToShow && !offline && artistsQuery.isError && (
<ErrorState onRetry={() => artistsQuery.refetch()} />
)}
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
{artistsToShow && artistsToShow.length === 0 && (
<EmptyState
icon="🎤"
title={t('library.empty.artists.title')}
description={t('library.empty.artists.description')}
title={t(
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' }}>
{artistsQuery.data.items.map((artist) => (
<ArtistRow key={artist.id} artist={artist} />
{artistsToShow.map((artist) => (
<ArtistRow
key={artist.id}
artist={artist}
onClick={() => void navigate(`/artists/${artist.id}`)}
/>
))}
</div>
)}
@@ -222,7 +300,12 @@ export function LibraryPage() {
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
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 (
<Card
onClick={onClick}
@@ -295,15 +378,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();
return (
<div
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 1.5rem',
cursor: 'pointer',
}}
>
<div
+665 -3
View File
@@ -1,13 +1,675 @@
import type { ReactNode } from 'react';
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() {
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 (
<div style={{ padding: '1.5rem' }}>
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
<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>
</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,
}}
/>
);
}
+46 -1
View File
@@ -6,8 +6,23 @@ import {
buildUploadFormData,
useUploadTrackMutation,
} from '../../api/endpoints/upload';
import { useGetTrackQuery } from '../../api/endpoints/library';
import {
useGetTrackQuery,
useGetTracksQuery,
} from '../../api/endpoints/library';
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
* it survives a page refresh — unlike the transient client-side queue above. */
const RECENT_UPLOADS = {
source: 'upload',
sortBy: 'dateAdded',
sortOrder: 'desc',
pageSize: 20,
} as const;
/** Pure client-side state — this is a transient upload queue, never server data. */
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
@@ -40,6 +55,10 @@ export function UploadPage() {
const [items, setItems] = useState<QueueItem[]>([]);
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 idCounter = useRef(0);
const activeCount = useRef(0);
@@ -228,6 +247,32 @@ export function UploadPage() {
))}
</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>
</ScrollArea>
</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 accessToken = useAppSelector((s) => s.auth.accessToken);
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(() => {
if (isSetup.current) return;
@@ -41,7 +45,12 @@ export function useAudioPlayer() {
dispatch(setDuration(audio.duration || 0));
});
audio.addEventListener('ended', () => {
dispatch(nextTrack());
if (loopRef.current) {
audio.currentTime = 0;
void audio.play();
} else {
dispatch(nextTrack());
}
});
audio.addEventListener('pause', () => {
dispatch(pause());
+29 -1
View File
@@ -1,7 +1,12 @@
import { useState, useEffect } from 'react';
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). */
export function useConnectionStatus(baseUrl?: string) {
@@ -36,3 +41,26 @@ export function useConnectionStatus(baseUrl?: string) {
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';
}
+4
View File
@@ -9,6 +9,8 @@ export interface ResolvedQueueEntry {
albumTitle: string;
durationMs: number;
hasCover: boolean;
albumArtUrl?: string;
format?: string;
}
/**
@@ -33,5 +35,7 @@ export function useResolvedQueueEntry(
albumTitle: data?.albumTitle ?? entry.albumTitle,
durationMs: data?.durationMs ?? entry.durationMs,
hasCover: data?.hasCover ?? false,
albumArtUrl: data?.albumArtUrl ?? entry.albumArtUrl,
format: data?.format,
};
}
+91 -6
View File
@@ -97,6 +97,13 @@ const en = {
artistRow: {
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: {
type: 'Album',
@@ -107,6 +114,27 @@ const en = {
title: '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: {
type: 'Playlist',
@@ -120,27 +148,25 @@ const en = {
},
player: {
nothingPlaying: 'Nothing playing',
shuffle: 'Shuffle',
previous: 'Previous',
next: 'Next',
pause: 'Pause',
play: 'Play',
repeat: 'Repeat: {{mode}}',
streaming: 'Streaming · 320 kbps',
local: 'Local · FLAC',
streaming: 'Streaming',
local: 'Local',
queue: 'Play queue',
mute: 'Mute',
unmute: 'Unmute',
},
queue: {
title: 'Play queue',
shuffle: 'Shuffle queue',
loop: 'Repeat current track',
clear: 'Clear queue',
close: 'Close',
from: 'From {{source}}',
radio: 'Radio · {{source}}',
nowPlaying: 'Now playing',
nextUp: 'Next up',
nothingNext: 'Nothing queued next',
empty: 'Queue is empty',
radioActive: 'Radio active',
mixing: '∞ mixing',
@@ -149,6 +175,13 @@ const en = {
loadingMore: 'Loading more from radio…',
doubleClickPlay: 'Double-click to play',
removeFromQueue: 'Remove from queue',
menu: {
options: 'Track options',
playNow: 'Play now',
moveNext: 'Move next',
info: 'Track info',
remove: 'Remove from queue',
},
},
track: {
menu: {
@@ -204,6 +237,54 @@ const en = {
retry: 'Retry',
comingSoon: 'Coming soon',
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: {
admin: 'Admin',
@@ -256,6 +337,10 @@ const en = {
clearCompleted: 'Clear completed',
retry: 'Retry',
editMetadata: 'Edit metadata',
recent: {
title: 'Recently uploaded',
empty: 'Nothing uploaded yet.',
},
metadataPending:
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
unknownArtist: 'Unknown Artist · metadata pending',
+92 -6
View File
@@ -99,6 +99,13 @@ const ru: Translations = {
artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков',
},
offline: {
banner:
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
emptyTitle: 'Офлайн ничего нет',
emptyDesc:
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
},
},
album: {
type: 'Альбом',
@@ -109,6 +116,27 @@ const ru: Translations = {
title: 'Нет треков',
description: 'В этом альбоме нет треков.',
},
offline: {
title: 'Альбом недоступен офлайн',
description: 'Нет связи, а этот альбом не сохранён на устройстве.',
},
},
artist: {
type: 'Исполнитель',
play: '▶ Слушать всё',
error: 'Не удалось загрузить исполнителя',
meta: '{{albumCount}} альбомов · {{trackCount}} треков',
albums: 'Альбомы',
tracks: 'Треки',
noAlbums: 'Пока нет альбомов.',
empty: {
title: 'Нет треков',
description: 'У этого исполнителя нет треков.',
},
offline: {
title: 'Исполнитель недоступен офлайн',
description: 'Нет связи, а этот исполнитель не сохранён на устройстве.',
},
},
playlist: {
type: 'Плейлист',
@@ -122,27 +150,25 @@ const ru: Translations = {
},
player: {
nothingPlaying: 'Ничего не играет',
shuffle: 'Перемешать',
previous: 'Назад',
next: 'Вперёд',
pause: 'Пауза',
play: 'Воспроизвести',
repeat: 'Повтор: {{mode}}',
streaming: 'Стриминг · 320 kbps',
local: 'Локально · FLAC',
streaming: 'Стриминг',
local: 'Локально',
queue: 'Очередь',
mute: 'Выключить звук',
unmute: 'Включить звук',
},
queue: {
title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь',
loop: 'Повторять текущий трек',
clear: 'Очистить очередь',
close: 'Закрыть',
from: 'Из: {{source}}',
radio: 'Радио · {{source}}',
nowPlaying: 'Сейчас играет',
nextUp: 'Далее',
nothingNext: 'Очередь пуста',
empty: 'Очередь пуста',
radioActive: 'Радио активно',
mixing: '∞ микс',
@@ -151,6 +177,13 @@ const ru: Translations = {
loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди',
menu: {
options: 'Параметры трека',
playNow: 'Воспроизвести сейчас',
moveNext: 'Сделать следующим',
info: 'Информация о треке',
remove: 'Убрать из очереди',
},
},
track: {
menu: {
@@ -206,6 +239,55 @@ const ru: Translations = {
retry: 'Повторить',
comingSoon: 'Скоро',
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: {
admin: 'Администрирование',
@@ -258,6 +340,10 @@ const ru: Translations = {
clearCompleted: 'Убрать завершённые',
retry: 'Повторить',
editMetadata: 'Изменить метаданные',
recent: {
title: 'Недавно загруженные',
empty: 'Пока ничего не загружено.',
},
metadataPending:
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
unknownArtist: 'Unknown Artist · метаданные в ожидании',
+14
View File
@@ -26,6 +26,20 @@ export function formatDateTime(iso: string | undefined): string | undefined {
}).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 {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
+2
View File
@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import { api } from '../api';
import authReducer from './slices/auth';
import connectionReducer from './slices/connection';
import playerReducer from './slices/player';
import queueReducer from './slices/queue';
import uiReducer from './slices/ui';
@@ -11,6 +12,7 @@ export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
auth: authReducer,
connection: connectionReducer,
player: playerReducer,
queue: queueReducer,
ui: uiReducer,
+12 -12
View File
@@ -8,14 +8,8 @@
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
*/
import { instanceStorage } from '../config/instances';
import {
queueInitialState,
type QueueState,
} from './slices/queue';
import {
playerInitialState,
type PlayerState,
} from './slices/player';
import { queueInitialState, type QueueState } from './slices/queue';
import { playerInitialState, type PlayerState } from './slices/player';
import type { RootState } from './index';
const QUEUE_KEY = 'queue';
@@ -26,11 +20,17 @@ const PLAYER_KEY = 'player';
// transient UI, so they are intentionally left out.
type PersistedQueue = Pick<
QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
| 'entries'
| 'currentIndex'
| 'source'
| 'sourceId'
| 'sourceName'
| 'shuffle'
| 'loop'
>;
type PersistedPlayer = Pick<
PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
'currentTrackId' | 'position' | 'volume' | 'muted'
>;
function pickQueue(state: QueueState): PersistedQueue {
@@ -40,6 +40,8 @@ function pickQueue(state: QueueState): PersistedQueue {
source: state.source,
sourceId: state.sourceId,
sourceName: state.sourceName,
shuffle: state.shuffle,
loop: state.loop,
};
}
@@ -49,8 +51,6 @@ function pickPlayer(state: PlayerState): PersistedPlayer {
position: state.position,
volume: state.volume,
muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
};
}
+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;
-14
View File
@@ -1,7 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type RepeatMode = 'none' | 'one' | 'all';
export interface PlayerState {
currentTrackId: string | null;
isPlaying: boolean;
@@ -9,8 +7,6 @@ export interface PlayerState {
duration: number;
volume: number;
muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isQueueOpen: boolean;
}
@@ -21,8 +17,6 @@ export const playerInitialState: PlayerState = {
duration: 0,
volume: 0.78,
muted: false,
repeat: 'none',
shuffle: false,
isQueueOpen: false,
};
@@ -58,12 +52,6 @@ export const playerSlice = createSlice({
toggleMute(state) {
state.muted = !state.muted;
},
setRepeat(state, action: PayloadAction<RepeatMode>) {
state.repeat = action.payload;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen;
},
@@ -79,8 +67,6 @@ export const {
setDuration,
setVolume,
toggleMute,
setRepeat,
toggleShuffle,
toggleQueue,
} = playerSlice.actions;
export default playerSlice.reducer;
+27 -1
View File
@@ -23,6 +23,8 @@ export interface QueueState {
source: QueueSource;
sourceId: string | null;
sourceName: string | null;
shuffle: boolean;
loop: boolean;
}
export const queueInitialState: QueueState = {
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
source: 'manual',
sourceId: null,
sourceName: null,
shuffle: false,
loop: false,
};
export const queueSlice = createSlice({
@@ -59,6 +63,11 @@ export const queueSlice = createSlice({
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
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>) {
state.entries.splice(action.payload, 1);
if (action.payload < state.currentIndex) state.currentIndex--;
@@ -77,7 +86,15 @@ export const queueSlice = createSlice({
state.currentIndex = action.payload;
},
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) {
if (state.currentIndex > 0) state.currentIndex--;
@@ -86,6 +103,12 @@ export const queueSlice = createSlice({
state.entries = [];
state.currentIndex = -1;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleLoop(state) {
state.loop = !state.loop;
},
},
});
@@ -93,11 +116,14 @@ export const {
setQueue,
addToQueue,
addNextInQueue,
playNow,
removeFromQueue,
moveInQueue,
goToIndex,
nextTrack,
prevTrack,
clearQueue,
toggleShuffle,
toggleLoop,
} = queueSlice.actions;
export default queueSlice.reducer;
+160 -42
View File
@@ -404,6 +404,55 @@
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
============================================================ */
@@ -602,38 +651,9 @@
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
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 {
display: flex;
gap: 11px;
@@ -649,6 +669,24 @@
.qrow:hover {
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 {
color: var(--fg-3);
font-size: 15px;
@@ -663,23 +701,46 @@
font-size: 13px;
font-weight: 500;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qrow .qt .r {
font-size: 11px;
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);
font-size: 11px;
/* News-ticker text: clips by default, ping-pong scrolls only when it overflows
(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 {
margin-bottom: 14px;
@@ -922,3 +983,60 @@
.sb-sec-link.active {
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']);
});