Compare commits
10 Commits
808c52484c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 89cf66f28a | |||
| f5a6b919aa | |||
| 231887c3b7 | |||
| cdcacc56d1 | |||
| b966ad8be5 | |||
| 6595417246 | |||
| 94361899a8 | |||
| 8a0e6782ad | |||
| 4aa071eeeb | |||
| 45a624b642 |
@@ -7,15 +7,15 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Number of tagged (non-latest) versions to keep per image name.
|
# Number of tagged (non-latest) versions to keep per image name.
|
||||||
KEEP_VERSIONS: "5"
|
KEEP_VERSIONS: '5'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
host: ${{ steps.meta.outputs.host }}
|
host: ${{ steps.meta.outputs.host }}
|
||||||
image: ${{ steps.meta.outputs.image }}
|
image: ${{ steps.meta.outputs.image }}
|
||||||
sha: ${{ steps.meta.outputs.sha }}
|
sha: ${{ steps.meta.outputs.sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
+5
-2
@@ -60,7 +60,9 @@ async function handleAudio(event) {
|
|||||||
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
|
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
|
||||||
// complete copy, then satisfy the original request (range-sliced if asked).
|
// complete copy, then satisfy the original request (range-sliced if asked).
|
||||||
try {
|
try {
|
||||||
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) });
|
const fullReq = new Request(req.url, {
|
||||||
|
headers: withoutRange(req.headers),
|
||||||
|
});
|
||||||
const resp = await fetch(fullReq);
|
const resp = await fetch(fullReq);
|
||||||
if (isCacheable(resp)) {
|
if (isCacheable(resp)) {
|
||||||
event.waitUntil(storeInCache(key, resp.clone()));
|
event.waitUntil(storeInCache(key, resp.clone()));
|
||||||
@@ -135,7 +137,8 @@ async function buildRangeResponse(response, rangeHeader) {
|
|||||||
const buf = await response.clone().arrayBuffer();
|
const buf = await response.clone().arrayBuffer();
|
||||||
const size = buf.byteLength;
|
const size = buf.byteLength;
|
||||||
const r = parseRangeHeader(rangeHeader, size);
|
const r = parseRangeHeader(rangeHeader, size);
|
||||||
const type = response.headers.get('content-type') || 'application/octet-stream';
|
const type =
|
||||||
|
response.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
|
||||||
if (!r) {
|
if (!r) {
|
||||||
return new Response(buf, {
|
return new Response(buf, {
|
||||||
|
|||||||
@@ -1,30 +1,88 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import type { DownloadJob } from '../types';
|
import {
|
||||||
|
toDownloadJob,
|
||||||
|
toPage,
|
||||||
|
type RawDownloadJob,
|
||||||
|
type RawPaged,
|
||||||
|
} from '../mappers';
|
||||||
|
import type {
|
||||||
|
DownloadJob,
|
||||||
|
DownloadRequestResult,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they
|
interface ListParams {
|
||||||
// return no body / no schema). The request shapes below are provisional and the
|
status?: DownloadJob['status'];
|
||||||
// responses will need the same snake→camel mapper treatment as library/playlists
|
/** Only the current user's jobs (backend `mine=true`). */
|
||||||
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
|
mine?: boolean;
|
||||||
// wire these into the UI until then.
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCreateResponse {
|
||||||
|
already_in_library: boolean;
|
||||||
|
track_id: string | null;
|
||||||
|
job: RawDownloadJob | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloadsApi = api.injectEndpoints({
|
export const downloadsApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getDownloads: build.query<
|
getDownloads: build.query<
|
||||||
DownloadJob[],
|
PaginatedResponse<DownloadJob>,
|
||||||
{ status?: DownloadJob['status'] } | void
|
ListParams | void
|
||||||
>({
|
>({
|
||||||
query: (params) => ({ url: '/downloads', params: params ?? {} }),
|
query: (params) => {
|
||||||
providesTags: ['Download'],
|
const p = params ?? {};
|
||||||
|
const size = p.pageSize ?? 50;
|
||||||
|
return {
|
||||||
|
url: '/downloads',
|
||||||
|
params: {
|
||||||
|
status: p.status,
|
||||||
|
mine: p.mine ? true : undefined,
|
||||||
|
limit: size,
|
||||||
|
offset: ((p.page ?? 1) - 1) * size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
transformResponse: (raw: RawPaged<RawDownloadJob>) =>
|
||||||
|
toPage(raw, toDownloadJob),
|
||||||
|
providesTags: (result) =>
|
||||||
|
result
|
||||||
|
? [
|
||||||
|
...result.items.map(({ id }) => ({
|
||||||
|
type: 'Download' as const,
|
||||||
|
id,
|
||||||
|
})),
|
||||||
|
'Download',
|
||||||
|
]
|
||||||
|
: ['Download'],
|
||||||
}),
|
}),
|
||||||
addDownload: build.mutation<
|
getDownload: build.query<DownloadJob, string>({
|
||||||
DownloadJob,
|
query: (id) => `/downloads/${id}`,
|
||||||
{
|
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||||
url: string;
|
providesTags: (_r, _e, id) => [{ type: 'Download', id }],
|
||||||
metadata?: { title?: string; artist?: string; album?: string };
|
}),
|
||||||
}
|
/** Request a download of a discovered item (§A4 "Download to library"). */
|
||||||
|
createDownload: build.mutation<
|
||||||
|
DownloadRequestResult,
|
||||||
|
{ source: string; sourceId: string; query?: string }
|
||||||
>({
|
>({
|
||||||
query: (body) => ({ url: '/downloads', method: 'POST', body }),
|
query: (body) => ({
|
||||||
invalidatesTags: ['Download'],
|
url: '/downloads',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
source: body.source,
|
||||||
|
source_id: body.sourceId,
|
||||||
|
query: body.query,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transformResponse: (raw: RawCreateResponse): DownloadRequestResult => ({
|
||||||
|
alreadyInLibrary: raw.already_in_library,
|
||||||
|
trackId: raw.track_id ?? undefined,
|
||||||
|
job: raw.job ? toDownloadJob(raw.job) : undefined,
|
||||||
|
}),
|
||||||
|
// A completed dedup can surface an existing library track; refresh both.
|
||||||
|
invalidatesTags: ['Download', 'Track'],
|
||||||
}),
|
}),
|
||||||
cancelDownload: build.mutation<void, string>({
|
cancelDownload: build.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
||||||
@@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
retryDownload: build.mutation<DownloadJob, string>({
|
retryDownload: build.mutation<DownloadJob, string>({
|
||||||
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
|
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
|
||||||
invalidatesTags: ['Download'],
|
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||||
|
invalidatesTags: (_r, _e, id) => [{ type: 'Download', id }, 'Download'],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
@@ -40,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetDownloadsQuery,
|
useGetDownloadsQuery,
|
||||||
useAddDownloadMutation,
|
useGetDownloadQuery,
|
||||||
|
useCreateDownloadMutation,
|
||||||
useCancelDownloadMutation,
|
useCancelDownloadMutation,
|
||||||
useRetryDownloadMutation,
|
useRetryDownloadMutation,
|
||||||
} = downloadsApi;
|
} = downloadsApi;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function trackParams(f: LibraryFilters) {
|
|||||||
q: f.search,
|
q: f.search,
|
||||||
artist_id: f.artistId,
|
artist_id: f.artistId,
|
||||||
album_id: f.albumId,
|
album_id: f.albumId,
|
||||||
|
source: f.source,
|
||||||
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
||||||
order: f.sortOrder,
|
order: f.sortOrder,
|
||||||
...paging(f.page, f.pageSize),
|
...paging(f.page, f.pageSize),
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { api } from '../index';
|
||||||
|
import {
|
||||||
|
toSearchResult,
|
||||||
|
toSourceInfo,
|
||||||
|
type RawSearchResult,
|
||||||
|
type RawSourceInfo,
|
||||||
|
} from '../mappers';
|
||||||
|
import type { ExternalSearchResult, SourceInfo } from '../types';
|
||||||
|
|
||||||
|
interface RawSearchResponse {
|
||||||
|
results: RawSearchResult[];
|
||||||
|
searched_sources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
results: ExternalSearchResult[];
|
||||||
|
/** Names of the sources actually queried (available ones). */
|
||||||
|
searchedSources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapSearch = (raw: RawSearchResponse): SearchResponse => ({
|
||||||
|
results: raw.results.map(toSearchResult),
|
||||||
|
searchedSources: raw.searched_sources,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchApi = api.injectEndpoints({
|
||||||
|
endpoints: (build) => ({
|
||||||
|
/** Registered source backends (for the §A4 source picker). */
|
||||||
|
getSources: build.query<SourceInfo[], void>({
|
||||||
|
query: () => '/sources',
|
||||||
|
transformResponse: (raw: RawSourceInfo[]) => raw.map(toSourceInfo),
|
||||||
|
}),
|
||||||
|
/** Search across every available fetch source (no `source` → aggregate). */
|
||||||
|
searchExternal: build.query<
|
||||||
|
SearchResponse,
|
||||||
|
{ q: string; source?: string; limit?: number }
|
||||||
|
>({
|
||||||
|
query: ({ q, source, limit }) =>
|
||||||
|
source
|
||||||
|
? { url: `/sources/${source}/search`, params: { q, limit } }
|
||||||
|
: { url: '/search', params: { q, limit } },
|
||||||
|
transformResponse: mapSearch,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { useGetSourcesQuery, useLazySearchExternalQuery } = searchApi;
|
||||||
@@ -32,3 +32,18 @@ export function getTrackCoverUrl(
|
|||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover image URL for an album, served by `GET /albums/{id}/cover`. Same
|
||||||
|
* `?token=` rationale as the track cover. Returns undefined when the album has
|
||||||
|
* no cover (so callers fall back to generated tile art).
|
||||||
|
*/
|
||||||
|
export function getAlbumCoverUrl(
|
||||||
|
albumId: string,
|
||||||
|
token: string,
|
||||||
|
hasCover: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasCover) return undefined;
|
||||||
|
const base = getApiBaseUrl();
|
||||||
|
return `${base}/albums/${albumId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
|
|||||||
export const api = createApi({
|
export const api = createApi({
|
||||||
reducerPath: 'api',
|
reducerPath: 'api',
|
||||||
baseQuery: baseQueryWithReauth,
|
baseQuery: baseQueryWithReauth,
|
||||||
|
// Stale-while-revalidate. The Tier-2 rehydrated cache (below) seeds fulfilled
|
||||||
|
// entries at startup, which would otherwise make RTKQ serve stale data and
|
||||||
|
// never hit the network. These flags keep showing the cached snapshot
|
||||||
|
// instantly but silently refetch from the server whenever it's reachable —
|
||||||
|
// on mount/arg change, on reconnect, and on window refocus. The result: the
|
||||||
|
// server is the source of truth when online; the cache is only a fallback.
|
||||||
|
refetchOnMountOrArgChange: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnFocus: true,
|
||||||
tagTypes: [
|
tagTypes: [
|
||||||
'Track',
|
'Track',
|
||||||
'Album',
|
'Album',
|
||||||
|
|||||||
@@ -14,10 +14,14 @@
|
|||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
DownloadJob,
|
||||||
|
DownloadStatus,
|
||||||
|
ExternalSearchResult,
|
||||||
MetadataMatch,
|
MetadataMatch,
|
||||||
MetadataStatus,
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
SourceInfo,
|
||||||
StorageStats,
|
StorageStats,
|
||||||
Track,
|
Track,
|
||||||
User,
|
User,
|
||||||
@@ -95,6 +99,7 @@ export interface RawAlbum {
|
|||||||
artist_name: string;
|
artist_name: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
track_count: number;
|
track_count: number;
|
||||||
|
has_cover: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +176,10 @@ export const toAlbum = (r: RawAlbum): Album => ({
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
artistId: r.artist_id,
|
artistId: r.artist_id,
|
||||||
artistName: r.artist_name,
|
artistName: r.artist_name,
|
||||||
|
// The album record carries no cover *URL*; `hasCover` says one exists, and the
|
||||||
|
// URL (which needs `?token=`) is built in components via `getAlbumCoverUrl`.
|
||||||
artUrl: undefined,
|
artUrl: undefined,
|
||||||
|
hasCover: r.has_cover,
|
||||||
year: r.year ?? undefined,
|
year: r.year ?? undefined,
|
||||||
trackCount: r.track_count,
|
trackCount: r.track_count,
|
||||||
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
||||||
@@ -242,6 +250,86 @@ export const toStorageStats = (r: RawStorageStats): StorageStats => ({
|
|||||||
disk: r.disk ?? undefined,
|
disk: r.disk ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- downloads + external search ----
|
||||||
|
|
||||||
|
const DOWNLOAD_STATUSES: readonly DownloadStatus[] = [
|
||||||
|
'queued',
|
||||||
|
'downloading',
|
||||||
|
'enriching',
|
||||||
|
'done',
|
||||||
|
'failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const toDownloadStatus = (raw: string): DownloadStatus =>
|
||||||
|
(DOWNLOAD_STATUSES as readonly string[]).includes(raw)
|
||||||
|
? (raw as DownloadStatus)
|
||||||
|
: 'queued';
|
||||||
|
|
||||||
|
export interface RawDownloadJob {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
source_id: string | null;
|
||||||
|
query: string | null;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
error_message: string | null;
|
||||||
|
retry_count: number;
|
||||||
|
track_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toDownloadJob = (r: RawDownloadJob): DownloadJob => ({
|
||||||
|
id: r.id,
|
||||||
|
source: r.source,
|
||||||
|
sourceId: r.source_id ?? undefined,
|
||||||
|
query: r.query ?? undefined,
|
||||||
|
status: toDownloadStatus(r.status),
|
||||||
|
progress: r.progress,
|
||||||
|
errorMessage: r.error_message ?? undefined,
|
||||||
|
trackId: r.track_id ?? undefined,
|
||||||
|
retryCount: r.retry_count,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RawSearchResult {
|
||||||
|
source: string;
|
||||||
|
source_id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string | null;
|
||||||
|
album: string | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSearchResult = (r: RawSearchResult): ExternalSearchResult => ({
|
||||||
|
source: r.source,
|
||||||
|
sourceId: r.source_id,
|
||||||
|
title: r.title,
|
||||||
|
artist: r.artist ?? undefined,
|
||||||
|
album: r.album ?? undefined,
|
||||||
|
durationMs:
|
||||||
|
r.duration_seconds != null ? r.duration_seconds * 1000 : undefined,
|
||||||
|
thumbnailUrl: r.thumbnail_url ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RawSourceInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSourceInfo = (r: RawSourceInfo): SourceInfo => ({
|
||||||
|
name: r.name,
|
||||||
|
label: r.label,
|
||||||
|
// Backend kinds are `indexable` | `fetch`; default unknowns to indexable
|
||||||
|
// (the conservative, non-searchable bucket).
|
||||||
|
kind: r.kind === 'fetch' ? 'fetch' : 'indexable',
|
||||||
|
available: r.available,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||||
|
|||||||
+51
-5
@@ -44,6 +44,8 @@ export interface Album {
|
|||||||
artistId: string;
|
artistId: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artUrl?: string;
|
artUrl?: string;
|
||||||
|
/** Whether the album has cover art served by `GET /albums/{id}/cover`. */
|
||||||
|
hasCover: boolean;
|
||||||
year?: number;
|
year?: number;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
totalDurationMs: number;
|
totalDurationMs: number;
|
||||||
@@ -76,20 +78,62 @@ export interface PlaylistTrack extends Track {
|
|||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lifecycle of a download job, mirroring the backend's `DownloadStatus`.
|
||||||
|
* `enriching` = file fetched, metadata pipeline running; `done` = imported. */
|
||||||
|
export type DownloadStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'downloading'
|
||||||
|
| 'enriching'
|
||||||
|
| 'done'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
export interface DownloadJob {
|
export interface DownloadJob {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
/** Source backend the job pulls from (e.g. `youtube`). */
|
||||||
title?: string;
|
source: string;
|
||||||
artist?: string;
|
/** Stable per-source id of the item (e.g. a YouTube videoId). */
|
||||||
album?: string;
|
sourceId?: string;
|
||||||
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error';
|
/** The free-text query the job was created from, for display. */
|
||||||
|
query?: string;
|
||||||
|
status: DownloadStatus;
|
||||||
|
/** Fraction complete, 0..1. */
|
||||||
progress: number;
|
progress: number;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/** Set once the download finishes and the library track exists. */
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
|
retryCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of POST /downloads. Either the item is already in the library
|
||||||
|
* (`alreadyInLibrary`, `trackId` set), or a job covers it (`job`). */
|
||||||
|
export interface DownloadRequestResult {
|
||||||
|
alreadyInLibrary: boolean;
|
||||||
|
trackId?: string;
|
||||||
|
job?: DownloadJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One hit from an external (fetch) source — the §A4 discover screen. */
|
||||||
|
export interface ExternalSearchResult {
|
||||||
|
source: string;
|
||||||
|
sourceId: string;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registered source backend, from GET /sources. `kind`: `indexable` (a
|
||||||
|
* mounted folder) or `fetch` (searchable + downloadable, e.g. YouTube). */
|
||||||
|
export interface SourceInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'indexable' | 'fetch';
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
track_id: string;
|
track_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -177,6 +221,8 @@ export interface LibraryFilters {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
|
/** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */
|
||||||
|
source?: string;
|
||||||
liked?: boolean;
|
liked?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
/** Single-line text that ping-pong scrolls (like a news ticker) only when it
|
||||||
|
* overflows its container, otherwise renders as static clipped text. Keeps the
|
||||||
|
* queue panel from ever growing a horizontal scrollbar on long titles. */
|
||||||
|
export function Marquee({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [shift, setShift] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const inner = el.firstElementChild as HTMLElement | null;
|
||||||
|
const overflow = (inner?.scrollWidth ?? 0) - el.clientWidth;
|
||||||
|
setShift(overflow > 1 ? overflow : 0);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={`marquee${shift ? ' on' : ''}${className ? ` ${className}` : ''}`}
|
||||||
|
style={
|
||||||
|
shift ? ({ '--mq-shift': `-${shift}px` } as CSSProperties) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="marquee-inner">{text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,24 @@ interface NavDef {
|
|||||||
|
|
||||||
const MAIN_NAV: NavDef[] = [
|
const MAIN_NAV: NavDef[] = [
|
||||||
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
||||||
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
|
{
|
||||||
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' },
|
to: '/discover',
|
||||||
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
|
labelKey: 'nav.search',
|
||||||
|
icon: 'magnifying-glass',
|
||||||
|
perm: 'download',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/downloads',
|
||||||
|
labelKey: 'nav.downloads',
|
||||||
|
icon: 'arrow-circle-down',
|
||||||
|
perm: 'download',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/upload',
|
||||||
|
labelKey: 'nav.upload',
|
||||||
|
icon: 'upload-simple',
|
||||||
|
perm: 'upload',
|
||||||
|
},
|
||||||
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Icon } from '../common/Icon';
|
import { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { Marquee } from '../common/Marquee';
|
||||||
import { PlayingIndicator } from '../common/PlayingIndicator';
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
@@ -259,13 +260,9 @@ function QueueRow({
|
|||||||
onDoubleClick={onPlay}
|
onDoubleClick={onPlay}
|
||||||
title={t('queue.doubleClickPlay')}
|
title={t('queue.doubleClickPlay')}
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
<span className="grip" {...attributes} {...listeners}>
|
||||||
<PlayingIndicator animate={isPlaying} />
|
<Icon name="dots-six-vertical" />
|
||||||
) : (
|
</span>
|
||||||
<span className="grip" {...attributes} {...listeners}>
|
|
||||||
<Icon name="dots-six-vertical" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="qart">
|
<div className="qart">
|
||||||
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
@@ -275,8 +272,11 @@ function QueueRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="qt">
|
<div className="qt">
|
||||||
<div className="t">{resolved?.title ?? entry.title}</div>
|
<Marquee className="t" text={resolved?.title ?? entry.title} />
|
||||||
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
<Marquee
|
||||||
|
className="r"
|
||||||
|
text={resolved?.artistName ?? entry.artistName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetAlbumQuery,
|
useGetAlbumQuery,
|
||||||
useGetAlbumTracksQuery,
|
useGetAlbumTracksQuery,
|
||||||
@@ -10,6 +10,11 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
|||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
@@ -24,15 +29,36 @@ export function AlbumDetailPage() {
|
|||||||
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
||||||
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
||||||
|
|
||||||
if (albumQuery.isLoading || tracksQuery.isLoading) {
|
// Offline fallback: resolve the album + its tracks from the locally-cached
|
||||||
return (
|
// library when the backend is unreachable (same approach as LibraryPage).
|
||||||
<div style={{ padding: '1.5rem' }}>
|
const offline = useIsOffline();
|
||||||
<LoadingSkeleton rows={10} />
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
</div>
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albumQuery.isError) {
|
const album =
|
||||||
|
albumQuery.data ??
|
||||||
|
(offline ? localAlbums.find((a) => a.id === albumId) : undefined);
|
||||||
|
const tracks =
|
||||||
|
tracksQuery.data ??
|
||||||
|
(offline ? localTracks.filter((tr) => tr.albumId === albumId) : []);
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
if (albumQuery.isLoading && !offline) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="💿"
|
||||||
|
title={t('album.offline.title')}
|
||||||
|
description={t('album.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.error')}
|
message={t('album.error')}
|
||||||
@@ -40,9 +66,6 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const album = albumQuery.data;
|
|
||||||
const tracks = tracksQuery.data ?? [];
|
|
||||||
// The album record itself carries no cover; fall back to a track's cover.
|
// The album record itself carries no cover; fall back to a track's cover.
|
||||||
const coverTrack = tracks.find((t) => t.hasCover);
|
const coverTrack = tracks.find((t) => t.hasCover);
|
||||||
const artUrl =
|
const artUrl =
|
||||||
@@ -72,6 +95,11 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
@@ -168,16 +196,17 @@ export function AlbumDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={10} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.tracksError')}
|
message={t('album.tracksError')}
|
||||||
onRetry={() => tracksQuery.refetch()}
|
onRetry={() => tracksQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!tracksQuery.isLoading &&
|
{tracks.length === 0 &&
|
||||||
!tracksQuery.isError &&
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
tracks.length === 0 && (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('album.empty.title')}
|
title={t('album.empty.title')}
|
||||||
|
|||||||
@@ -1,8 +1,307 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { ScrollArea, IconButton, Button, Card, Callout } from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useGetArtistQuery,
|
||||||
|
useGetArtistAlbumsQuery,
|
||||||
|
useGetArtistTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { ArtTile } from '../../components/common/ArtTile';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalArtists,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
|
import { setQueue } from '../../store/slices/queue';
|
||||||
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
import type { Album } from '../../api/types';
|
||||||
|
|
||||||
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
|
|
||||||
export function ArtistDetailPage() {
|
export function ArtistDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <Placeholder title={t('pages.artist')} />;
|
const { artistId } = useParams<{ artistId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const artistQuery = useGetArtistQuery(artistId ?? '', { skip: !artistId });
|
||||||
|
const albumsQuery = useGetArtistAlbumsQuery(artistId ?? '', {
|
||||||
|
skip: !artistId,
|
||||||
|
});
|
||||||
|
const tracksQuery = useGetArtistTracksQuery(artistId ?? '', {
|
||||||
|
skip: !artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offline fallback: resolve the artist + their albums/tracks from the
|
||||||
|
// locally-cached library when the backend is unreachable.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
|
||||||
|
const artist =
|
||||||
|
artistQuery.data ??
|
||||||
|
(offline ? localArtists.find((a) => a.id === artistId) : undefined);
|
||||||
|
const albums =
|
||||||
|
albumsQuery.data ??
|
||||||
|
(offline ? localAlbums.filter((a) => a.artistId === artistId) : []);
|
||||||
|
const tracks =
|
||||||
|
tracksQuery.data ??
|
||||||
|
(offline ? localTracks.filter((tr) => tr.artistId === artistId) : []);
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
if (artistQuery.isLoading && !offline) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="🎤"
|
||||||
|
title={t('artist.offline.title')}
|
||||||
|
description={t('artist.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={t('artist.error')}
|
||||||
|
onRetry={() => artistQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
dispatch(
|
||||||
|
setQueue({
|
||||||
|
entries: tracks.map((tr) => ({
|
||||||
|
trackId: tr.id,
|
||||||
|
title: tr.title,
|
||||||
|
artistName: tr.artistName,
|
||||||
|
albumTitle: tr.albumTitle,
|
||||||
|
durationMs: tr.durationMs,
|
||||||
|
albumArtUrl: tr.albumArtUrl,
|
||||||
|
})),
|
||||||
|
source: 'artist',
|
||||||
|
sourceId: artist.id,
|
||||||
|
sourceName: artist.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</IconButton>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArtTile seed={artist.id} label={artist.name} size={96} radius={48} />
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.type')}
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.meta', {
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
trackCount: artist.trackCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handlePlayAll}
|
||||||
|
disabled={!tracks.length}
|
||||||
|
>
|
||||||
|
{t('artist.play')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
{/* Discography */}
|
||||||
|
<section style={{ padding: '1.25rem 1.5rem 0' }}>
|
||||||
|
<h2
|
||||||
|
style={{ margin: '0 0 0.75rem', fontSize: '1rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{t('artist.albums')}
|
||||||
|
</h2>
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isLoading && (
|
||||||
|
<LoadingSkeleton rows={3} height={72} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 &&
|
||||||
|
(offline || (!albumsQuery.isLoading && !albumsQuery.isError)) && (
|
||||||
|
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
||||||
|
{t('artist.noAlbums')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(9rem, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* All tracks */}
|
||||||
|
<section style={{ padding: '1.5rem 0 0.5rem' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: '0 1.5rem 0.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.tracks')}
|
||||||
|
</h2>
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 &&
|
||||||
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
|
<EmptyState
|
||||||
|
icon="♫"
|
||||||
|
title={t('artist.empty.title')}
|
||||||
|
description={t('artist.empty.description')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tracks.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(album.artUrl) ??
|
||||||
|
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artUrl ? (
|
||||||
|
<img
|
||||||
|
src={artUrl}
|
||||||
|
alt={album.title}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', aspectRatio: '1' }}>
|
||||||
|
<ArtTile seed={album.id} label={album.title} size={144} radius={6} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{album.year ? `${album.year} · ` : ''}
|
||||||
|
{t('library.albumCard.tracksDuration', {
|
||||||
|
count: album.trackCount,
|
||||||
|
duration: formatDuration(album.totalDurationMs),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,275 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Progress,
|
||||||
|
ScrollArea,
|
||||||
|
Spinner,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useCancelDownloadMutation,
|
||||||
|
useGetDownloadsQuery,
|
||||||
|
useRetryDownloadMutation,
|
||||||
|
} from '../../api/endpoints/downloads';
|
||||||
|
import { Icon } from '../../components/common/Icon';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { formatDateTime } from '../../lib/format';
|
||||||
|
import type { DownloadJob, DownloadStatus } from '../../api/types';
|
||||||
|
|
||||||
|
const ACTIVE: readonly DownloadStatus[] = [
|
||||||
|
'queued',
|
||||||
|
'downloading',
|
||||||
|
'enriching',
|
||||||
|
];
|
||||||
|
const isActive = (s: DownloadStatus) => ACTIVE.includes(s);
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
DownloadStatus,
|
||||||
|
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
|
||||||
|
> = {
|
||||||
|
queued: { variant: 'neutral', key: 'downloads.status.queued' },
|
||||||
|
downloading: { variant: 'neutral', key: 'downloads.status.downloading' },
|
||||||
|
enriching: { variant: 'outline', key: 'downloads.status.enriching' },
|
||||||
|
done: { variant: 'lime', key: 'downloads.status.done' },
|
||||||
|
failed: { variant: 'ember', key: 'downloads.status.failed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `/downloads` — A5 download manager: active queue, progress, errors, retries. */
|
||||||
export function DownloadsManagerPage() {
|
export function DownloadsManagerPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Poll while anything is in flight; idle pages stop polling (tag invalidation
|
||||||
|
// from a new download still refetches and re-arms the interval).
|
||||||
|
const [pollMs, setPollMs] = useState(2000);
|
||||||
|
const { data, isLoading, isError, refetch } = useGetDownloadsQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
pollingInterval: pollMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const anyActive = data?.items.some((j) => isActive(j.status)) ?? false;
|
||||||
|
setPollMs(anyActive ? 1500 : 0);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const jobs = data?.items ?? [];
|
||||||
|
const active = jobs.filter((j) => isActive(j.status));
|
||||||
|
const finished = jobs.filter((j) => !isActive(j.status));
|
||||||
|
const failedCount = jobs.filter((j) => j.status === 'failed').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Window title={t('pages.downloads')}>
|
<header
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
style={{
|
||||||
</Window>
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('downloads.title')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('downloads.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Badge variant="neutral">
|
||||||
|
<Spinner size="sm" />{' '}
|
||||||
|
{t('downloads.activeCount', { count: active.length })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
maxWidth: 880,
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && <LoadingSkeleton rows={5} height={64} />}
|
||||||
|
{isError && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('downloads.loadError')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && jobs.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="arrow-circle-down" />}
|
||||||
|
title={t('downloads.emptyTitle')}
|
||||||
|
description={t('downloads.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<Callout variant="warning">
|
||||||
|
{t('downloads.failedBanner', { count: failedCount })}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Section title={t('downloads.sectionActive')}>
|
||||||
|
{active.map((job) => (
|
||||||
|
<JobRow key={job.id} job={job} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finished.length > 0 && (
|
||||||
|
<Section title={t('downloads.sectionHistory')}>
|
||||||
|
{finished.map((job) => (
|
||||||
|
<JobRow key={job.id} job={job} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobRow({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [cancel, { isLoading: cancelling }] = useCancelDownloadMutation();
|
||||||
|
const [retry, { isLoading: retrying }] = useRetryDownloadMutation();
|
||||||
|
|
||||||
|
const badge = STATUS_BADGE[job.status];
|
||||||
|
const label = job.query || job.sourceId || job.source;
|
||||||
|
const added = formatDateTime(job.createdAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{job.source}
|
||||||
|
{job.retryCount > 0 &&
|
||||||
|
` · ${t('downloads.attempt', { count: job.retryCount + 1 })}`}
|
||||||
|
{added && ` · ${added}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant={badge.variant} dot>
|
||||||
|
{t(badge.key)}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{job.status === 'done' && job.trackId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate(`/tracks/${job.trackId}/metadata`)}
|
||||||
|
>
|
||||||
|
{t('downloads.open')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={retrying}
|
||||||
|
onClick={() => void retry(job.id)}
|
||||||
|
>
|
||||||
|
<Icon name="arrows-clockwise" /> {t('downloads.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isActive(job.status) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={cancelling}
|
||||||
|
onClick={() => void cancel(job.id)}
|
||||||
|
title={t('downloads.cancel')}
|
||||||
|
>
|
||||||
|
<Icon name="x" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.status === 'downloading' && (
|
||||||
|
<Progress value={Math.round(job.progress * 100)} />
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && job.errorMessage && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{job.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
TextField,
|
TextField,
|
||||||
|
Callout,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
@@ -18,13 +19,33 @@ import { TrackRow } from '../../components/track/TrackRow';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalTracks,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalArtists,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import type { Track, Album, Artist } from '../../api/types';
|
import type { Track, Album, Artist } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useDebounce } from 'use-debounce';
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
/** Case-insensitive substring match used for client-side search while offline
|
||||||
|
* (the server can't run the query, so we filter the locally-cached library). */
|
||||||
|
function matchTrack(tr: Track, q: string): boolean {
|
||||||
|
return (
|
||||||
|
tr.title.toLowerCase().includes(q) ||
|
||||||
|
tr.artistName.toLowerCase().includes(q) ||
|
||||||
|
tr.albumTitle.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const matchAlbum = (a: Album, q: string): boolean =>
|
||||||
|
a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q);
|
||||||
|
const matchArtist = (a: Artist, q: string): boolean =>
|
||||||
|
a.name.toLowerCase().includes(q);
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -33,8 +54,14 @@ export function LibraryPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch] = useDebounce(search, 300);
|
const [debouncedSearch] = useDebounce(search, 300);
|
||||||
|
|
||||||
|
// Poll while any listed track is still being enriched, then stop. Enrichment
|
||||||
|
// runs asynchronously in a worker after import/upload; without this the row
|
||||||
|
// stays stuck on "Identifying metadata…" until something else invalidates the
|
||||||
|
// Track tag. Cleared to 0 once nothing is pending (and while offline).
|
||||||
|
const [tracksPollMs, setTracksPollMs] = useState(0);
|
||||||
const tracksQuery = useGetTracksQuery(
|
const tracksQuery = useGetTracksQuery(
|
||||||
debouncedSearch ? { search } : undefined,
|
debouncedSearch ? { search } : undefined,
|
||||||
|
{ pollingInterval: tracksPollMs },
|
||||||
);
|
);
|
||||||
const albumsQuery = useGetAlbumsQuery(
|
const albumsQuery = useGetAlbumsQuery(
|
||||||
debouncedSearch ? { search } : undefined,
|
debouncedSearch ? { search } : undefined,
|
||||||
@@ -43,6 +70,46 @@ export function LibraryPage() {
|
|||||||
debouncedSearch ? { search } : undefined,
|
debouncedSearch ? { search } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Offline fallback: when the backend is unreachable, compose the library from
|
||||||
|
// whatever the RTKQ cache holds locally (rehydrated last-seen + this session),
|
||||||
|
// filtering client-side since the server can't run the search.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
const anyPending =
|
||||||
|
!offline &&
|
||||||
|
(tracksQuery.data?.items.some((tr) => tr.metadataStatus === 'pending') ??
|
||||||
|
false);
|
||||||
|
useEffect(() => {
|
||||||
|
setTracksPollMs(anyPending ? 4000 : 0);
|
||||||
|
}, [anyPending]);
|
||||||
|
|
||||||
|
// Live server data wins; offline we fall back to the locally-composed list.
|
||||||
|
const tracksToShow =
|
||||||
|
tracksQuery.data?.items ??
|
||||||
|
(offline
|
||||||
|
? q
|
||||||
|
? localTracks.filter((tr) => matchTrack(tr, q))
|
||||||
|
: localTracks
|
||||||
|
: undefined);
|
||||||
|
const albumsToShow =
|
||||||
|
albumsQuery.data?.items ??
|
||||||
|
(offline
|
||||||
|
? q
|
||||||
|
? localAlbums.filter((a) => matchAlbum(a, q))
|
||||||
|
: localAlbums
|
||||||
|
: undefined);
|
||||||
|
const artistsToShow =
|
||||||
|
artistsQuery.data?.items ??
|
||||||
|
(offline
|
||||||
|
? q
|
||||||
|
? localArtists.filter((a) => matchArtist(a, q))
|
||||||
|
: localArtists
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const handlePlayAll = (tracks: Track[]) => {
|
const handlePlayAll = (tracks: Track[]) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
setQueue({
|
setQueue({
|
||||||
@@ -84,6 +151,12 @@ export function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('library.offline.banner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={setTab}
|
onValueChange={setTab}
|
||||||
@@ -112,74 +185,84 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
|
{!tracksToShow && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={12} />
|
||||||
|
)}
|
||||||
|
{!tracksToShow && !offline && tracksQuery.isError && (
|
||||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
{tracksToShow && tracksToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('library.empty.tracks.title')}
|
title={t(
|
||||||
description={t('library.empty.tracks.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.tracks.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.tracks.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data &&
|
{tracksToShow && tracksToShow.length > 0 && (
|
||||||
tracksQuery.data.items.length > 0 &&
|
<div>
|
||||||
(() => {
|
<div
|
||||||
const data = tracksQuery.data!;
|
style={{
|
||||||
return (
|
padding: '0.5rem 0.75rem',
|
||||||
<div>
|
display: 'flex',
|
||||||
<div
|
gap: '0.5rem',
|
||||||
style={{
|
alignItems: 'center',
|
||||||
padding: '0.5rem 0.75rem',
|
borderBottom: '1px solid var(--color-border)',
|
||||||
display: 'flex',
|
}}
|
||||||
gap: '0.5rem',
|
>
|
||||||
alignItems: 'center',
|
<button
|
||||||
borderBottom: '1px solid var(--color-border)',
|
onClick={() => handlePlayAll(tracksToShow)}
|
||||||
}}
|
style={{
|
||||||
>
|
background: 'none',
|
||||||
<button
|
border: 'none',
|
||||||
onClick={() => handlePlayAll(data.items)}
|
cursor: 'pointer',
|
||||||
style={{
|
color: 'var(--color-accent)',
|
||||||
background: 'none',
|
fontSize: '0.875rem',
|
||||||
border: 'none',
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
}}
|
||||||
color: 'var(--color-accent)',
|
>
|
||||||
fontSize: '0.875rem',
|
{t('library.playAll', { count: tracksToShow.length })}
|
||||||
fontWeight: 500,
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
{tracksToShow.map((track, i) => (
|
||||||
{t('library.playAll', { count: data.total })}
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
{data.items.map((track, i) => (
|
)}
|
||||||
<TrackRow
|
|
||||||
key={track.id}
|
|
||||||
track={track}
|
|
||||||
index={i}
|
|
||||||
showAlbum
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
|
{!albumsToShow && albumsQuery.isLoading && (
|
||||||
{albumsQuery.isError && (
|
<LoadingSkeleton rows={8} height={72} />
|
||||||
|
)}
|
||||||
|
{!albumsToShow && !offline && albumsQuery.isError && (
|
||||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
{albumsToShow && albumsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="💿"
|
icon="💿"
|
||||||
title={t('library.empty.albums.title')}
|
title={t(
|
||||||
description={t('library.empty.albums.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.albums.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.albums.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && (
|
{albumsToShow && albumsToShow.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -188,7 +271,7 @@ export function LibraryPage() {
|
|||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{albumsQuery.data.items.map((album) => (
|
{albumsToShow.map((album) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
@@ -202,21 +285,35 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
|
{!artistsToShow && artistsQuery.isLoading && (
|
||||||
{artistsQuery.isError && (
|
<LoadingSkeleton rows={8} />
|
||||||
|
)}
|
||||||
|
{!artistsToShow && !offline && artistsQuery.isError && (
|
||||||
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
{artistsToShow && artistsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🎤"
|
icon="🎤"
|
||||||
title={t('library.empty.artists.title')}
|
title={t(
|
||||||
description={t('library.empty.artists.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.artists.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.artists.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && (
|
{artistsToShow && artistsToShow.length > 0 && (
|
||||||
<div style={{ padding: '0.5rem 0' }}>
|
<div style={{ padding: '0.5rem 0' }}>
|
||||||
{artistsQuery.data.items.map((artist) => (
|
{artistsToShow.map((artist) => (
|
||||||
<ArtistRow key={artist.id} artist={artist} />
|
<ArtistRow
|
||||||
|
key={artist.id}
|
||||||
|
artist={artist}
|
||||||
|
onClick={() => void navigate(`/artists/${artist.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -229,7 +326,12 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const artUrl = getCoverUrl(album.artUrl);
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
// The album record has no cover URL; build one from `hasCover` (served by
|
||||||
|
// GET /albums/{id}/cover, token in the query — <img> can't send a header).
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(album.artUrl) ??
|
||||||
|
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -302,15 +404,23 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtistRow({ artist }: { artist: Artist }) {
|
function ArtistRow({
|
||||||
|
artist,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
artist: Artist;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
padding: '0.5rem 1.5rem',
|
padding: '0.5rem 1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge, Button, Callout, Card, IconButton, ScrollArea, Spinner, TextField } from '@olly/modern-sk';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Card,
|
||||||
|
IconButton,
|
||||||
|
ScrollArea,
|
||||||
|
Spinner,
|
||||||
|
TextField,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useApplyMetadataMutation,
|
useApplyMetadataMutation,
|
||||||
useEnrichTrackMutation,
|
useEnrichTrackMutation,
|
||||||
@@ -75,7 +84,9 @@ function SingleTrackEditor() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Seed the form from the loaded track exactly once — afterwards it's the
|
// Seed the form from the loaded track exactly once — afterwards it's the
|
||||||
// user's edit buffer and shouldn't be clobbered by refetches.
|
// user's edit buffer and shouldn't be clobbered by refetches.
|
||||||
@@ -139,7 +150,9 @@ function SingleTrackEditor() {
|
|||||||
albumTitle: form.albumTitle.trim() || undefined,
|
albumTitle: form.albumTitle.trim() || undefined,
|
||||||
year: form.year.trim() ? Number(form.year) : undefined,
|
year: form.year.trim() ? Number(form.year) : undefined,
|
||||||
genre: form.genre.trim() || undefined,
|
genre: form.genre.trim() || undefined,
|
||||||
trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined,
|
trackNumber: form.trackNumber.trim()
|
||||||
|
? Number(form.trackNumber)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
};
|
};
|
||||||
@@ -210,7 +223,9 @@ function SingleTrackEditor() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.title')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.title')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.title}
|
value={form.title}
|
||||||
@@ -218,7 +233,9 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.artist')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.artist')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.artistName}
|
value={form.artistName}
|
||||||
@@ -226,7 +243,9 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.album')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.album')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.albumTitle}
|
value={form.albumTitle}
|
||||||
@@ -235,7 +254,9 @@ function SingleTrackEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.year')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.year')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -244,7 +265,9 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.trackNumber')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.trackNumber')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -254,7 +277,9 @@ function SingleTrackEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.genre')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.genre')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.genre}
|
value={form.genre}
|
||||||
@@ -262,13 +287,23 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
disabled={applyResult.isLoading}
|
disabled={applyResult.isLoading}
|
||||||
>
|
>
|
||||||
{applyResult.isLoading ? <Spinner size="sm" /> : t('metadataEditor.save')}
|
{applyResult.isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.save')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,7 +330,12 @@ function SingleTrackEditor() {
|
|||||||
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
{t('metadataEditor.autoEnrich.title')}
|
{t('metadataEditor.autoEnrich.title')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('metadataEditor.autoEnrich.hint')}
|
{t('metadataEditor.autoEnrich.hint')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,18 +368,31 @@ function SingleTrackEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enrichResult.isSuccess && (
|
{enrichResult.isSuccess && (
|
||||||
<Callout variant="info">{t('metadataEditor.autoEnrich.enqueued')}</Callout>
|
<Callout variant="info">
|
||||||
|
{t('metadataEditor.autoEnrich.enqueued')}
|
||||||
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{matchesResult.isError && (
|
{matchesResult.isError && (
|
||||||
<Callout variant="danger">{t('metadataEditor.autoEnrich.error')}</Callout>
|
<Callout variant="danger">
|
||||||
|
{t('metadataEditor.autoEnrich.error')}
|
||||||
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{matchesResult.isSuccess && matchesResult.data && (
|
{matchesResult.isSuccess &&
|
||||||
matchesResult.data.length === 0 ? (
|
matchesResult.data &&
|
||||||
<Callout variant="warning">{t('metadataEditor.autoEnrich.noMatches')}</Callout>
|
(matchesResult.data.length === 0 ? (
|
||||||
|
<Callout variant="warning">
|
||||||
|
{t('metadataEditor.autoEnrich.noMatches')}
|
||||||
|
</Callout>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{matchesResult.data.map((match) => (
|
{matchesResult.data.map((match) => (
|
||||||
<MatchRow
|
<MatchRow
|
||||||
key={match.acoustid}
|
key={match.acoustid}
|
||||||
@@ -348,8 +401,7 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedMatch && (
|
{selectedMatch && (
|
||||||
<DiffView
|
<DiffView
|
||||||
@@ -367,7 +419,13 @@ function SingleTrackEditor() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) {
|
function MatchRow({
|
||||||
|
match,
|
||||||
|
onUse,
|
||||||
|
}: {
|
||||||
|
match: MetadataMatch;
|
||||||
|
onUse: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pct = Math.round(match.score * 100);
|
const pct = Math.round(match.score * 100);
|
||||||
return (
|
return (
|
||||||
@@ -489,11 +547,20 @@ function DiffView({
|
|||||||
{t('metadataEditor.diff.noChanges')}
|
{t('metadataEditor.diff.noChanges')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}
|
||||||
|
>
|
||||||
{changed.map((row) => (
|
{changed.map((row) => (
|
||||||
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
||||||
<span style={{ color: 'var(--color-text-3)' }}>{row.label}: </span>
|
<span style={{ color: 'var(--color-text-3)' }}>
|
||||||
<span style={{ textDecoration: 'line-through', color: 'var(--color-text-3)' }}>
|
{row.label}:{' '}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{row.current || '—'}
|
{row.current || '—'}
|
||||||
</span>
|
</span>
|
||||||
{' → '}
|
{' → '}
|
||||||
@@ -504,11 +571,18 @@ function DiffView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
<div
|
||||||
|
style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
{t('metadataEditor.diff.cancel')}
|
{t('metadataEditor.diff.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" onClick={onApply} disabled={changed.length === 0}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={changed.length === 0}
|
||||||
|
>
|
||||||
{t('metadataEditor.diff.apply')}
|
{t('metadataEditor.diff.apply')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,358 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
ScrollArea,
|
||||||
|
SearchField,
|
||||||
|
SegmentedControl,
|
||||||
|
Spinner,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useGetSourcesQuery,
|
||||||
|
useLazySearchExternalQuery,
|
||||||
|
} from '../../api/endpoints/search';
|
||||||
|
import { useCreateDownloadMutation } from '../../api/endpoints/downloads';
|
||||||
|
import { Icon } from '../../components/common/Icon';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import type { ExternalSearchResult } from '../../api/types';
|
||||||
|
|
||||||
|
const ALL = '__all__';
|
||||||
|
|
||||||
|
/** Per-result download outcome, keyed by `${source}:${sourceId}`. */
|
||||||
|
type RowState = 'idle' | 'pending' | 'queued' | 'inLibrary' | 'error';
|
||||||
|
|
||||||
|
const rowKey = (r: ExternalSearchResult) => `${r.source}:${r.sourceId}`;
|
||||||
|
|
||||||
|
/** `/discover` — A4: search external sources and download into the library. */
|
||||||
export function SearchDownloadPage() {
|
export function SearchDownloadPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const sourcesQuery = useGetSourcesQuery();
|
||||||
|
const fetchSources = (sourcesQuery.data ?? []).filter(
|
||||||
|
(s) => s.kind === 'fetch',
|
||||||
|
);
|
||||||
|
const hasFetchSource = fetchSources.length > 0;
|
||||||
|
|
||||||
|
const [term, setTerm] = useState('');
|
||||||
|
const [source, setSource] = useState(ALL);
|
||||||
|
const [search, result] = useLazySearchExternalQuery();
|
||||||
|
const [createDownload] = useCreateDownloadMutation();
|
||||||
|
|
||||||
|
const [rowStates, setRowStates] = useState<Record<string, RowState>>({});
|
||||||
|
const [queuedAny, setQueuedAny] = useState(false);
|
||||||
|
|
||||||
|
const runSearch = (q: string) => {
|
||||||
|
if (!q) return;
|
||||||
|
setRowStates({});
|
||||||
|
void search({ q, source: source === ALL ? undefined : source, limit: 25 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
runSearch(term.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (r: ExternalSearchResult) => {
|
||||||
|
const key = rowKey(r);
|
||||||
|
setRowStates((s) => ({ ...s, [key]: 'pending' }));
|
||||||
|
try {
|
||||||
|
const res = await createDownload({
|
||||||
|
source: r.source,
|
||||||
|
sourceId: r.sourceId,
|
||||||
|
query: term.trim() || undefined,
|
||||||
|
}).unwrap();
|
||||||
|
setRowStates((s) => ({
|
||||||
|
...s,
|
||||||
|
[key]: res.alreadyInLibrary ? 'inLibrary' : 'queued',
|
||||||
|
}));
|
||||||
|
if (!res.alreadyInLibrary) setQueuedAny(true);
|
||||||
|
} catch {
|
||||||
|
setRowStates((s) => ({ ...s, [key]: 'error' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickerItems = [
|
||||||
|
{ value: ALL, label: t('discover.allSources') },
|
||||||
|
...fetchSources.map((s) => ({ value: s.name, label: s.label })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Window title={t('pages.search')}>
|
<header
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
style={{
|
||||||
</Window>
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('discover.title')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('discover.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{queuedAny && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate('/downloads')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-circle-down" /> {t('discover.viewDownloads')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
style={{ display: 'flex', gap: '0.625rem', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<SearchField
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
placeholder={t('discover.searchPlaceholder')}
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
disabled={!hasFetchSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasFetchSource || !term.trim()}
|
||||||
|
>
|
||||||
|
{t('discover.searchButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{fetchSources.length > 1 && (
|
||||||
|
<SegmentedControl
|
||||||
|
value={source}
|
||||||
|
onValueChange={setSource}
|
||||||
|
items={pickerItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div style={{ padding: '1.5rem', maxWidth: 880, margin: '0 auto' }}>
|
||||||
|
{!sourcesQuery.isLoading && !hasFetchSource && (
|
||||||
|
<Callout variant="warning">{t('discover.noSources')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.isFetching && <LoadingSkeleton rows={6} height={64} />}
|
||||||
|
|
||||||
|
{result.isError && !result.isFetching && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('discover.searchError')}
|
||||||
|
onRetry={() => runSearch(term.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result.isFetching &&
|
||||||
|
!result.isError &&
|
||||||
|
result.data &&
|
||||||
|
result.data.results.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
title={t('discover.emptyTitle')}
|
||||||
|
description={t('discover.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.isUninitialized && hasFetchSource && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
title={t('discover.startTitle')}
|
||||||
|
description={t('discover.startDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result.isFetching &&
|
||||||
|
result.data &&
|
||||||
|
result.data.results.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.data.results.map((r) => (
|
||||||
|
<ResultRow
|
||||||
|
key={rowKey(r)}
|
||||||
|
result={r}
|
||||||
|
state={rowStates[rowKey(r)] ?? 'idle'}
|
||||||
|
onDownload={() => void download(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultRow({
|
||||||
|
result,
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
result: ExternalSearchResult;
|
||||||
|
state: RowState;
|
||||||
|
onDownload: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.875rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thumb url={result.thumbnailUrl} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={result.title}
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[result.artist, result.album].filter(Boolean).join(' · ') ||
|
||||||
|
result.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.durationMs != null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(result.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DownloadControl state={state} onDownload={onDownload} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadControl({
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
state: RowState;
|
||||||
|
onDownload: () => void;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
if (state === 'inLibrary') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" dot>
|
||||||
|
{t('discover.inLibrary')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'queued') {
|
||||||
|
return (
|
||||||
|
<Badge variant="lime" dot>
|
||||||
|
{t('discover.queued')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'pending') {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" type="button" disabled>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button variant="primary" size="sm" type="button" onClick={onDownload}>
|
||||||
|
<Icon name="arrow-circle-down" />
|
||||||
|
{state === 'error' ? t('discover.retryDownload') : t('discover.download')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ url }: { url?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
width={44}
|
||||||
|
height={44}
|
||||||
|
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon name="vinyl-record" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
|
|||||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||||
|
|
||||||
/** Labelled settings row: caption on the left, control on the right. */
|
/** Labelled settings row: caption on the left, control on the right. */
|
||||||
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
function SettingRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window, Card, Badge } from '@olly/modern-sk';
|
import { Window, Card, Badge, Callout } from '@olly/modern-sk';
|
||||||
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
|
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { Icon, type IconName } from '../../components/common/Icon';
|
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 {
|
import {
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
formatCount,
|
formatCount,
|
||||||
@@ -26,6 +35,13 @@ const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
|
|||||||
export function StoragePage() {
|
export function StoragePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
||||||
|
const offline = useIsOffline();
|
||||||
|
|
||||||
|
// Tier-3 audio cache + the locally-cached library metadata = "this device".
|
||||||
|
const audio = useAudioCacheStats();
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
@@ -34,23 +50,172 @@ export function StoragePage() {
|
|||||||
{t('storage.subtitle')}
|
{t('storage.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
<div
|
||||||
{isError && (
|
style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}
|
||||||
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
>
|
||||||
)}
|
{/* ── On this device (local + cached) ───────────────────────── */}
|
||||||
{data && data.totalTracks === 0 && (
|
<div>
|
||||||
<EmptyState
|
<SectionTitle icon="hard-drives">
|
||||||
icon={<Icon name="hard-drives" />}
|
{t('storage.device')}
|
||||||
title={t('storage.emptyTitle')}
|
</SectionTitle>
|
||||||
description={t('storage.emptyDesc')}
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
/>
|
<LocalStoragePanel
|
||||||
)}
|
audio={audio}
|
||||||
{data && data.totalTracks > 0 && <StorageDashboard stats={data} />}
|
trackCount={localTracks.length}
|
||||||
|
albumCount={localAlbums.length}
|
||||||
|
artistCount={localArtists.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── On the server (remote) ────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle icon="cloud">{t('storage.server')}</SectionTitle>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
||||||
|
{isError && offline && (
|
||||||
|
<Callout variant="info">
|
||||||
|
{t('storage.serverUnreachable')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{isError && !offline && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('common.error')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="hard-drives" />}
|
||||||
|
title={t('storage.emptyTitle')}
|
||||||
|
description={t('storage.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks > 0 && (
|
||||||
|
<StorageDashboard stats={data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "On this device": the SW audio cache (downloaded audio) + the offline
|
||||||
|
* library metadata we can browse without the server. */
|
||||||
|
function LocalStoragePanel({
|
||||||
|
audio,
|
||||||
|
trackCount,
|
||||||
|
albumCount,
|
||||||
|
artistCount,
|
||||||
|
}: {
|
||||||
|
audio: AudioCacheStats | null;
|
||||||
|
trackCount: number;
|
||||||
|
albumCount: number;
|
||||||
|
artistCount: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="arrow-circle-down">
|
||||||
|
{t('storage.audioCache')}
|
||||||
|
</SectionTitle>
|
||||||
|
{audio ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${audio.maxBytes > 0 ? Math.min((audio.bytes / audio.maxBytes) * 100, 100) : 0}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.6rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.audioCacheUsage', {
|
||||||
|
used: formatFileSize(audio.bytes),
|
||||||
|
max: formatFileSize(audio.maxBytes),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.cachedTracks', { n: audio.count })}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.audioCacheUnavailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">
|
||||||
|
{t('storage.offlineLibrary')}
|
||||||
|
</SectionTitle>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCount(trackCount)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.35rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.offlineLibraryMeta', {
|
||||||
|
tracks: formatCount(trackCount),
|
||||||
|
albums: formatCount(albumCount),
|
||||||
|
artists: formatCount(artistCount),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
||||||
@@ -184,7 +349,9 @@ function DiskGauge({
|
|||||||
total: formatFileSize(disk.total),
|
total: formatFileSize(disk.total),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span>{t('storage.diskFree', { free: formatFileSize(disk.free) })}</span>
|
<span>
|
||||||
|
{t('storage.diskFree', { free: formatFileSize(disk.free) })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -6,8 +6,23 @@ import {
|
|||||||
buildUploadFormData,
|
buildUploadFormData,
|
||||||
useUploadTrackMutation,
|
useUploadTrackMutation,
|
||||||
} from '../../api/endpoints/upload';
|
} from '../../api/endpoints/upload';
|
||||||
import { useGetTrackQuery } from '../../api/endpoints/library';
|
import {
|
||||||
|
useGetTrackQuery,
|
||||||
|
useGetTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
|
||||||
|
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
|
||||||
|
* it survives a page refresh — unlike the transient client-side queue above. */
|
||||||
|
const RECENT_UPLOADS = {
|
||||||
|
source: 'upload',
|
||||||
|
sortBy: 'dateAdded',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
pageSize: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||||
@@ -25,7 +40,10 @@ const MAX_CONCURRENCY = 3;
|
|||||||
|
|
||||||
function extractError(err: unknown): string {
|
function extractError(err: unknown): string {
|
||||||
if (typeof err === 'object' && err !== null) {
|
if (typeof err === 'object' && err !== null) {
|
||||||
const e = err as { data?: { message?: string; detail?: string }; error?: string };
|
const e = err as {
|
||||||
|
data?: { message?: string; detail?: string };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
||||||
}
|
}
|
||||||
return 'Upload failed';
|
return 'Upload failed';
|
||||||
@@ -40,18 +58,27 @@ export function UploadPage() {
|
|||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
// Persisted view of past uploads. Auto-refreshes after each upload because the
|
||||||
|
// upload mutation invalidates the `Track` tag this query provides.
|
||||||
|
const recentQuery = useGetTracksQuery(RECENT_UPLOADS);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const idCounter = useRef(0);
|
const idCounter = useRef(0);
|
||||||
const activeCount = useRef(0);
|
const activeCount = useRef(0);
|
||||||
const pending = useRef<QueueItem[]>([]);
|
const pending = useRef<QueueItem[]>([]);
|
||||||
|
|
||||||
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
||||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.id === id ? { ...it, ...patch } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
||||||
// async callbacks without stale closures over the queue.
|
// async callbacks without stale closures over the queue.
|
||||||
const pump = () => {
|
const pump = () => {
|
||||||
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
|
while (
|
||||||
|
activeCount.current < MAX_CONCURRENCY &&
|
||||||
|
pending.current.length > 0
|
||||||
|
) {
|
||||||
const item = pending.current.shift()!;
|
const item = pending.current.shift()!;
|
||||||
activeCount.current += 1;
|
activeCount.current += 1;
|
||||||
patchItem(item.id, { status: 'uploading', error: undefined });
|
patchItem(item.id, { status: 'uploading', error: undefined });
|
||||||
@@ -163,7 +190,9 @@ export function UploadPage() {
|
|||||||
>
|
>
|
||||||
<div style={{ fontSize: '2rem' }}>⬆</div>
|
<div style={{ fontSize: '2rem' }}>⬆</div>
|
||||||
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
<div
|
||||||
|
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
|
||||||
|
>
|
||||||
{t('upload.dropzone.hint')}
|
{t('upload.dropzone.hint')}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="sm" type="button">
|
<Button variant="primary" size="sm" type="button">
|
||||||
@@ -183,7 +212,11 @@ export function UploadPage() {
|
|||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -228,6 +261,32 @@ export function UploadPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
{t('upload.recent.title')}
|
||||||
|
</span>
|
||||||
|
{recentQuery.isLoading && <LoadingSkeleton rows={4} />}
|
||||||
|
{recentQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => recentQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{recentQuery.data && recentQuery.data.items.length === 0 && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('upload.recent.empty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentQuery.data?.items.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -97,6 +97,13 @@ const en = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
"You're offline — showing the library available locally. It may be incomplete and is read-only until the server is back.",
|
||||||
|
emptyTitle: 'Nothing available offline',
|
||||||
|
emptyDesc:
|
||||||
|
'No library data is cached on this device yet. Connect to the server once to browse offline.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Album',
|
type: 'Album',
|
||||||
@@ -107,6 +114,28 @@ const en = {
|
|||||||
title: 'No tracks',
|
title: 'No tracks',
|
||||||
description: 'This album has no tracks.',
|
description: 'This album has no tracks.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Album not available offline',
|
||||||
|
description: "You're offline and this album isn't cached on this device.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Artist',
|
||||||
|
play: '▶ Play all',
|
||||||
|
error: 'Failed to load artist',
|
||||||
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
|
albums: 'Albums',
|
||||||
|
tracks: 'Tracks',
|
||||||
|
noAlbums: 'No albums yet.',
|
||||||
|
empty: {
|
||||||
|
title: 'No tracks',
|
||||||
|
description: 'This artist has no tracks.',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Artist not available offline',
|
||||||
|
description:
|
||||||
|
"You're offline and this artist isn't cached on this device.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Playlist',
|
type: 'Playlist',
|
||||||
@@ -209,9 +238,22 @@ const en = {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
|
offlineBanner:
|
||||||
|
"You're offline — showing locally available data, read-only.",
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
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',
|
emptyTitle: 'Nothing stored yet',
|
||||||
emptyDesc:
|
emptyDesc:
|
||||||
'Download or upload some music and your library stats will appear here.',
|
'Download or upload some music and your library stats will appear here.',
|
||||||
@@ -297,6 +339,10 @@ const en = {
|
|||||||
clearCompleted: 'Clear completed',
|
clearCompleted: 'Clear completed',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
|
recent: {
|
||||||
|
title: 'Recently uploaded',
|
||||||
|
empty: 'Nothing uploaded yet.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
||||||
unknownArtist: 'Unknown Artist · metadata pending',
|
unknownArtist: 'Unknown Artist · metadata pending',
|
||||||
@@ -308,6 +354,48 @@ const en = {
|
|||||||
error: 'Failed',
|
error: 'Failed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discover: {
|
||||||
|
title: 'Search & download',
|
||||||
|
subtitle: 'Find music on connected sources and add it to your library.',
|
||||||
|
searchPlaceholder: 'Search for a song, artist, or album…',
|
||||||
|
searchButton: 'Search',
|
||||||
|
allSources: 'All sources',
|
||||||
|
noSources:
|
||||||
|
'No download sources are configured. Enable a source (e.g. YouTube Music) on the server to search and download.',
|
||||||
|
startTitle: 'Search to get started',
|
||||||
|
startDesc: 'Results from your connected sources will appear here.',
|
||||||
|
emptyTitle: 'No results',
|
||||||
|
emptyDesc: 'Try a different search term or another source.',
|
||||||
|
searchError: "Couldn't search right now. Try again.",
|
||||||
|
download: 'Download',
|
||||||
|
retryDownload: 'Retry',
|
||||||
|
queued: 'Queued',
|
||||||
|
inLibrary: 'In library',
|
||||||
|
viewDownloads: 'View downloads',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
title: 'Downloads',
|
||||||
|
subtitle: 'Track downloads in progress, completed, and failed.',
|
||||||
|
activeCount: '{{count}} active',
|
||||||
|
sectionActive: 'In progress',
|
||||||
|
sectionHistory: 'History',
|
||||||
|
emptyTitle: 'No downloads yet',
|
||||||
|
emptyDesc: 'Find music in Search & download to queue it here.',
|
||||||
|
loadError: "Couldn't load downloads.",
|
||||||
|
failedBanner:
|
||||||
|
'{{count}} download(s) failed. Retry them, or check the source on the server.',
|
||||||
|
attempt: 'Attempt {{count}}',
|
||||||
|
open: 'Open',
|
||||||
|
retry: 'Retry',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
status: {
|
||||||
|
queued: 'Queued',
|
||||||
|
downloading: 'Downloading',
|
||||||
|
enriching: 'Enriching',
|
||||||
|
done: 'Done',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
status: {
|
status: {
|
||||||
pending: 'Enriching…',
|
pending: 'Enriching…',
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ const ru: Translations = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
|
||||||
|
emptyTitle: 'Офлайн ничего нет',
|
||||||
|
emptyDesc:
|
||||||
|
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Альбом',
|
type: 'Альбом',
|
||||||
@@ -109,6 +116,27 @@ const ru: Translations = {
|
|||||||
title: 'Нет треков',
|
title: 'Нет треков',
|
||||||
description: 'В этом альбоме нет треков.',
|
description: 'В этом альбоме нет треков.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Альбом недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот альбом не сохранён на устройстве.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Исполнитель',
|
||||||
|
play: '▶ Слушать всё',
|
||||||
|
error: 'Не удалось загрузить исполнителя',
|
||||||
|
meta: '{{albumCount}} альбомов · {{trackCount}} треков',
|
||||||
|
albums: 'Альбомы',
|
||||||
|
tracks: 'Треки',
|
||||||
|
noAlbums: 'Пока нет альбомов.',
|
||||||
|
empty: {
|
||||||
|
title: 'Нет треков',
|
||||||
|
description: 'У этого исполнителя нет треков.',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Исполнитель недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот исполнитель не сохранён на устройстве.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Плейлист',
|
type: 'Плейлист',
|
||||||
@@ -211,9 +239,22 @@ const ru: Translations = {
|
|||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
comingSoon: 'Скоро',
|
comingSoon: 'Скоро',
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
|
offlineBanner:
|
||||||
|
'Нет связи с сервером — показаны локально доступные данные, только для чтения.',
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Всё, что хранит этот инстанс',
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
device: 'На этом устройстве',
|
||||||
|
server: 'На сервере',
|
||||||
|
audioCache: 'Кэш аудио',
|
||||||
|
audioCacheUsage: 'Занято {{used}} из {{max}}',
|
||||||
|
cachedTracks: '{{n}} треков сохранено офлайн',
|
||||||
|
audioCacheUnavailable:
|
||||||
|
'Офлайн-кэш аудио недоступен (service worker не активен).',
|
||||||
|
offlineLibrary: 'Офлайн-библиотека',
|
||||||
|
offlineLibraryMeta:
|
||||||
|
'{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн',
|
||||||
|
serverUnreachable: 'Сервер недоступен — показано только это устройство.',
|
||||||
emptyTitle: 'Пока ничего не сохранено',
|
emptyTitle: 'Пока ничего не сохранено',
|
||||||
emptyDesc:
|
emptyDesc:
|
||||||
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
||||||
@@ -299,6 +340,10 @@ const ru: Translations = {
|
|||||||
clearCompleted: 'Убрать завершённые',
|
clearCompleted: 'Убрать завершённые',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
editMetadata: 'Изменить метаданные',
|
editMetadata: 'Изменить метаданные',
|
||||||
|
recent: {
|
||||||
|
title: 'Недавно загруженные',
|
||||||
|
empty: 'Пока ничего не загружено.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
||||||
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
||||||
@@ -310,6 +355,49 @@ const ru: Translations = {
|
|||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discover: {
|
||||||
|
title: 'Поиск и скачивание',
|
||||||
|
subtitle:
|
||||||
|
'Находите музыку в подключённых источниках и добавляйте в библиотеку.',
|
||||||
|
searchPlaceholder: 'Найти трек, исполнителя или альбом…',
|
||||||
|
searchButton: 'Найти',
|
||||||
|
allSources: 'Все источники',
|
||||||
|
noSources:
|
||||||
|
'Источники скачивания не настроены. Включите источник (например, YouTube Music) на сервере, чтобы искать и скачивать.',
|
||||||
|
startTitle: 'Начните с поиска',
|
||||||
|
startDesc: 'Здесь появятся результаты из подключённых источников.',
|
||||||
|
emptyTitle: 'Ничего не найдено',
|
||||||
|
emptyDesc: 'Попробуйте другой запрос или другой источник.',
|
||||||
|
searchError: 'Не удалось выполнить поиск. Попробуйте ещё раз.',
|
||||||
|
download: 'Скачать',
|
||||||
|
retryDownload: 'Повторить',
|
||||||
|
queued: 'В очереди',
|
||||||
|
inLibrary: 'В библиотеке',
|
||||||
|
viewDownloads: 'К загрузкам',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
title: 'Загрузки',
|
||||||
|
subtitle: 'Активные, завершённые и неуспешные скачивания.',
|
||||||
|
activeCount: 'Активных: {{count}}',
|
||||||
|
sectionActive: 'В процессе',
|
||||||
|
sectionHistory: 'История',
|
||||||
|
emptyTitle: 'Пока нет загрузок',
|
||||||
|
emptyDesc: 'Найдите музыку в разделе «Поиск и скачивание».',
|
||||||
|
loadError: 'Не удалось загрузить список.',
|
||||||
|
failedBanner:
|
||||||
|
'Неуспешных скачиваний: {{count}}. Повторите их или проверьте источник на сервере.',
|
||||||
|
attempt: 'Попытка {{count}}',
|
||||||
|
open: 'Открыть',
|
||||||
|
retry: 'Повторить',
|
||||||
|
cancel: 'Отменить',
|
||||||
|
status: {
|
||||||
|
queued: 'В очереди',
|
||||||
|
downloading: 'Скачивание',
|
||||||
|
enriching: 'Обработка',
|
||||||
|
done: 'Готово',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
},
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
status: {
|
status: {
|
||||||
pending: 'Обработка…',
|
pending: 'Обработка…',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import './api/endpoints/auth';
|
|||||||
import './api/endpoints/library';
|
import './api/endpoints/library';
|
||||||
import './api/endpoints/playlists';
|
import './api/endpoints/playlists';
|
||||||
import './api/endpoints/downloads';
|
import './api/endpoints/downloads';
|
||||||
|
import './api/endpoints/search';
|
||||||
import './api/endpoints/likes';
|
import './api/endpoints/likes';
|
||||||
import './api/endpoints/storage';
|
import './api/endpoints/storage';
|
||||||
import './api/endpoints/admin';
|
import './api/endpoints/admin';
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ const DownloadsManagerPage = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const UploadPage = lazy(() =>
|
const UploadPage = lazy(() =>
|
||||||
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })),
|
import('../features/upload/UploadPage').then((m) => ({
|
||||||
|
default: m.UploadPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const MetadataEditorPage = lazy(() =>
|
const MetadataEditorPage = lazy(() =>
|
||||||
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
||||||
@@ -126,7 +128,10 @@ export function AppRoutes() {
|
|||||||
|
|
||||||
{/* Storage */}
|
{/* Storage */}
|
||||||
<Route path="/storage" element={<StoragePage />} />
|
<Route path="/storage" element={<StoragePage />} />
|
||||||
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} />
|
<Route
|
||||||
|
path="/storage/maintenance"
|
||||||
|
element={<StorageMaintenancePage />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Queue (narrow viewports) */}
|
{/* Queue (narrow viewports) */}
|
||||||
<Route path="/queue" element={<QueuePage />} />
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { setupListeners } from '@reduxjs/toolkit/query';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import authReducer from './slices/auth';
|
import authReducer from './slices/auth';
|
||||||
import connectionReducer from './slices/connection';
|
import connectionReducer from './slices/connection';
|
||||||
@@ -27,6 +28,10 @@ export const store = configureStore({
|
|||||||
getDefaultMiddleware().concat(api.middleware),
|
getDefaultMiddleware().concat(api.middleware),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enable refetchOnReconnect / refetchOnFocus by dispatching the browser's
|
||||||
|
// online + focus events into RTKQ (no-op without the api flags set in api/index).
|
||||||
|
setupListeners(store.dispatch);
|
||||||
|
|
||||||
// Flush queue/player changes back to localStorage (throttled).
|
// Flush queue/player changes back to localStorage (throttled).
|
||||||
startPersistence(store);
|
startPersistence(store);
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ function snapshot(apiState: ApiState): RehydrateApiPayload {
|
|||||||
}
|
}
|
||||||
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
||||||
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
||||||
return { queries, mutations: {}, provided: apiState.provided ?? EMPTY_PROVIDED };
|
return {
|
||||||
|
queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: apiState.provided ?? EMPTY_PROVIDED,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(): RehydrateApiPayload | null {
|
function load(): RehydrateApiPayload | null {
|
||||||
|
|||||||
@@ -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()];
|
||||||
|
},
|
||||||
|
);
|
||||||
+36
-12
@@ -651,6 +651,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: 12px 12px 18px;
|
padding: 12px 12px 18px;
|
||||||
}
|
}
|
||||||
.qrow {
|
.qrow {
|
||||||
@@ -700,23 +701,46 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.qrow .qt .r {
|
.qrow .qt .r {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
.qrow .qt .r .ph {
|
|
||||||
color: var(--lime);
|
/* News-ticker text: clips by default, ping-pong scrolls only when it overflows
|
||||||
font-size: 11px;
|
(the .on class is set by the Marquee component after measuring). */
|
||||||
|
.marquee {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.marquee-inner {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
.marquee.on .marquee-inner {
|
||||||
|
max-width: none;
|
||||||
|
animation: marquee-pingpong 9s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes marquee-pingpong {
|
||||||
|
0%,
|
||||||
|
12% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
88%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(var(--mq-shift, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.marquee.on .marquee-inner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.qd-radio {
|
.qd-radio {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -20,7 +20,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
function apiStateWith(queries: Record<string, unknown>) {
|
function apiStateWith(queries: Record<string, unknown>) {
|
||||||
return {
|
return {
|
||||||
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} },
|
api: {
|
||||||
|
queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: {},
|
||||||
|
subscriptions: {},
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
} as unknown as RootState;
|
} as unknown as RootState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from '../public/sw-core.js';
|
} from '../public/sw-core.js';
|
||||||
|
|
||||||
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
||||||
expect(
|
expect(trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz')).toBe(
|
||||||
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'),
|
'abc123',
|
||||||
).toBe('abc123');
|
);
|
||||||
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user