Compare commits
11 Commits
master
...
sloppyslop
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f7fd25ff | |||
| 5b31e77b1e | |||
| 7f116f0657 | |||
| ac6b76bf98 | |||
| e45ef785cf | |||
| 9d88d70be0 | |||
| 6efbf4b68b | |||
| 87fac7a5a1 | |||
| 3eba3701d0 | |||
| 28d9e59e34 | |||
| 57044dabba |
@@ -1,6 +1,11 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import { toUser, type RawUser } from '../mappers';
|
import { toUser, type RawUser } from '../mappers';
|
||||||
import type { User } from '../types';
|
import type { User, ServicesStatus, SourceInfo } from '../types';
|
||||||
|
|
||||||
|
interface ReindexJob {
|
||||||
|
source: string;
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin user management. The backend models authorization as `is_superuser` /
|
* Admin user management. The backend models authorization as `is_superuser` /
|
||||||
@@ -14,6 +19,11 @@ export const adminApi = api.injectEndpoints({
|
|||||||
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||||
providesTags: ['User'],
|
providesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
|
getUser: build.query<User, string>({
|
||||||
|
query: (id) => `/admin/users/${id}`,
|
||||||
|
transformResponse: (raw: RawUser) => toUser(raw),
|
||||||
|
providesTags: (_res, _err, id) => [{ type: 'User', id }],
|
||||||
|
}),
|
||||||
createUser: build.mutation<
|
createUser: build.mutation<
|
||||||
User,
|
User,
|
||||||
{ username: string; password: string; role: 'admin' | 'user' }
|
{ username: string; password: string; role: 'admin' | 'user' }
|
||||||
@@ -39,11 +49,34 @@ export const adminApi = api.injectEndpoints({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
transformResponse: (raw: RawUser) => toUser(raw),
|
transformResponse: (raw: RawUser) => toUser(raw),
|
||||||
invalidatesTags: ['User'],
|
invalidatesTags: (_res, _err, { id }) => ['User', { type: 'User', id }],
|
||||||
}),
|
}),
|
||||||
|
// Soft delete on the backend: deactivates the account and revokes its
|
||||||
|
// sessions (see admin.py::deactivate_user), so it also refreshes the row.
|
||||||
deleteUser: build.mutation<void, string>({
|
deleteUser: build.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
|
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
|
||||||
invalidatesTags: ['User'],
|
invalidatesTags: (_res, _err, id) => ['User', { type: 'User', id }],
|
||||||
|
}),
|
||||||
|
resetPassword: build.mutation<void, { id: string; newPassword: string }>({
|
||||||
|
query: ({ id, newPassword }) => ({
|
||||||
|
url: `/admin/users/${id}/reset-password`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { new_password: newPassword },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// -- instance (§A9) -------------------------------------------------------
|
||||||
|
getServices: build.query<ServicesStatus, void>({
|
||||||
|
query: () => '/admin/services',
|
||||||
|
}),
|
||||||
|
getSources: build.query<SourceInfo[], void>({
|
||||||
|
query: () => '/admin/sources',
|
||||||
|
}),
|
||||||
|
reindex: build.mutation<ReindexJob[], void>({
|
||||||
|
query: () => ({ url: '/admin/reindex', method: 'POST' }),
|
||||||
|
transformResponse: (raw: { jobs: { source: string; job_id: string }[] }) =>
|
||||||
|
raw.jobs.map((j) => ({ source: j.source, jobId: j.job_id })),
|
||||||
|
// A fresh scan changes the catalogue once the worker runs.
|
||||||
|
invalidatesTags: ['Track', 'Album', 'Artist', 'Storage'],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
@@ -51,7 +84,12 @@ export const adminApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetUsersQuery,
|
useGetUsersQuery,
|
||||||
|
useGetUserQuery,
|
||||||
useCreateUserMutation,
|
useCreateUserMutation,
|
||||||
useUpdateUserMutation,
|
useUpdateUserMutation,
|
||||||
useDeleteUserMutation,
|
useDeleteUserMutation,
|
||||||
|
useResetPasswordMutation,
|
||||||
|
useGetServicesQuery,
|
||||||
|
useGetSourcesQuery,
|
||||||
|
useReindexMutation,
|
||||||
} = adminApi;
|
} = adminApi;
|
||||||
|
|||||||
@@ -201,6 +201,18 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
||||||
}),
|
}),
|
||||||
|
// Enqueue a transcode to a cached Opus + HLS rendition (§6.6). Fire-and-
|
||||||
|
// forget: the worker does the work, playback keeps using the original.
|
||||||
|
optimizeTrack: build.mutation<
|
||||||
|
{ status: string; job_id: string; quality: string },
|
||||||
|
{ trackId: string; quality?: string }
|
||||||
|
>({
|
||||||
|
query: ({ trackId, quality = 'high' }) => ({
|
||||||
|
url: `/tracks/${trackId}/optimize`,
|
||||||
|
method: 'POST',
|
||||||
|
params: { quality },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -219,4 +231,5 @@ export const {
|
|||||||
useLazyGetMetadataMatchesQuery,
|
useLazyGetMetadataMatchesQuery,
|
||||||
useApplyMetadataMutation,
|
useApplyMetadataMutation,
|
||||||
useEnrichTrackMutation,
|
useEnrichTrackMutation,
|
||||||
|
useOptimizeTrackMutation,
|
||||||
} = libraryApi;
|
} = libraryApi;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { api } from '../index';
|
||||||
|
import type { Lyrics } from '../types';
|
||||||
|
|
||||||
|
// `GET /tracks/{id}/lyrics` — cached lyrics for the Now Playing panel (§6.7).
|
||||||
|
// A miss is a normal 200 with `status: 'not_found'`, not an error.
|
||||||
|
|
||||||
|
interface RawLyrics {
|
||||||
|
track_id: string;
|
||||||
|
status: string;
|
||||||
|
source: string | null;
|
||||||
|
synced: string | null;
|
||||||
|
plain: string | null;
|
||||||
|
synced_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLyrics = (r: RawLyrics): Lyrics => ({
|
||||||
|
trackId: r.track_id,
|
||||||
|
status: r.status as Lyrics['status'],
|
||||||
|
source: r.source ?? undefined,
|
||||||
|
synced: r.synced ?? undefined,
|
||||||
|
plain: r.plain ?? undefined,
|
||||||
|
syncedAvailable: r.synced_available,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const lyricsApi = api.injectEndpoints({
|
||||||
|
endpoints: (build) => ({
|
||||||
|
getLyrics: build.query<Lyrics, string>({
|
||||||
|
query: (trackId) => `/tracks/${trackId}/lyrics`,
|
||||||
|
transformResponse: (raw: RawLyrics) => toLyrics(raw),
|
||||||
|
}),
|
||||||
|
refetchLyrics: build.mutation<Lyrics, string>({
|
||||||
|
query: (trackId) => ({
|
||||||
|
url: `/tracks/${trackId}/lyrics/refetch`,
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
transformResponse: (raw: RawLyrics) => toLyrics(raw),
|
||||||
|
// Push the fresh result straight into the getLyrics cache for this track.
|
||||||
|
async onQueryStarted(trackId, { dispatch, queryFulfilled }) {
|
||||||
|
const { data } = await queryFulfilled;
|
||||||
|
dispatch(
|
||||||
|
lyricsApi.util.upsertQueryData('getLyrics', trackId, data),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { useGetLyricsQuery, useRefetchLyricsMutation } = lyricsApi;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { api } from '../index';
|
||||||
|
import { toTrack, type RawTrack } from '../mappers';
|
||||||
|
import type { RadioResult } from '../types';
|
||||||
|
|
||||||
|
// Radio + similarity (§6.5). Stateless: the client passes the seed + already-
|
||||||
|
// queued ids and pulls more as the queue drains.
|
||||||
|
|
||||||
|
interface RawRadioTrack {
|
||||||
|
track: RawTrack;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
interface RawRadio {
|
||||||
|
source: string;
|
||||||
|
tracks: RawRadioTrack[];
|
||||||
|
}
|
||||||
|
interface RawSimilar {
|
||||||
|
source: string;
|
||||||
|
tracks: RawTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioArgs {
|
||||||
|
seedTrackId?: string;
|
||||||
|
fromLikes?: boolean;
|
||||||
|
exploration?: number;
|
||||||
|
count?: number;
|
||||||
|
excludeIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBody = (a: RadioArgs) => ({
|
||||||
|
seed_track_id: a.seedTrackId ?? null,
|
||||||
|
from_likes: a.fromLikes ?? false,
|
||||||
|
exploration: a.exploration ?? 0.25,
|
||||||
|
count: a.count ?? 20,
|
||||||
|
exclude_ids: a.excludeIds ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const toRadio = (r: RawRadio): RadioResult => ({
|
||||||
|
source: r.source,
|
||||||
|
picks: r.tracks.map((x) => ({ track: toTrack(x.track), reason: x.reason })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const radioApi = api.injectEndpoints({
|
||||||
|
endpoints: (build) => ({
|
||||||
|
startRadio: build.mutation<RadioResult, RadioArgs>({
|
||||||
|
query: (a) => ({ url: '/radio', method: 'POST', body: toBody(a) }),
|
||||||
|
transformResponse: (r: RawRadio) => toRadio(r),
|
||||||
|
}),
|
||||||
|
radioNext: build.mutation<RadioResult, RadioArgs>({
|
||||||
|
query: (a) => ({ url: '/radio/next', method: 'POST', body: toBody(a) }),
|
||||||
|
transformResponse: (r: RawRadio) => toRadio(r),
|
||||||
|
}),
|
||||||
|
getSimilarTracks: build.query<RadioResult, string>({
|
||||||
|
query: (trackId) => `/tracks/${trackId}/similar`,
|
||||||
|
transformResponse: (r: RawSimilar) => ({
|
||||||
|
source: r.source,
|
||||||
|
picks: r.tracks.map((t) => ({ track: toTrack(t), reason: 'similar' })),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useStartRadioMutation,
|
||||||
|
useRadioNextMutation,
|
||||||
|
useGetSimilarTracksQuery,
|
||||||
|
} = radioApi;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { api } from '../index';
|
||||||
|
import type {
|
||||||
|
AppSettings,
|
||||||
|
ScrobbleProvider,
|
||||||
|
ScrobblingConfig,
|
||||||
|
StreamQuality,
|
||||||
|
ThemePref,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// Wire shapes (snake_case) from the backend, mapped to the app's camelCase types.
|
||||||
|
interface RawSettings {
|
||||||
|
theme: ThemePref;
|
||||||
|
stream_quality: StreamQuality;
|
||||||
|
}
|
||||||
|
interface RawScrobbling {
|
||||||
|
enabled: boolean;
|
||||||
|
provider: ScrobbleProvider | null;
|
||||||
|
username: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSettings = (r: RawSettings): AppSettings => ({
|
||||||
|
theme: r.theme,
|
||||||
|
streamQuality: r.stream_quality,
|
||||||
|
});
|
||||||
|
const toScrobbling = (r: RawScrobbling): ScrobblingConfig => ({
|
||||||
|
enabled: r.enabled,
|
||||||
|
provider: r.provider,
|
||||||
|
username: r.username,
|
||||||
|
configured: r.configured,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settingsApi = api.injectEndpoints({
|
||||||
|
endpoints: (build) => ({
|
||||||
|
getSettings: build.query<AppSettings, void>({
|
||||||
|
query: () => '/settings',
|
||||||
|
transformResponse: (raw: RawSettings) => toSettings(raw),
|
||||||
|
providesTags: ['Settings'],
|
||||||
|
}),
|
||||||
|
updateSettings: build.mutation<
|
||||||
|
AppSettings,
|
||||||
|
{ theme?: ThemePref; streamQuality?: StreamQuality }
|
||||||
|
>({
|
||||||
|
query: ({ theme, streamQuality }) => ({
|
||||||
|
url: '/settings',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { theme, stream_quality: streamQuality },
|
||||||
|
}),
|
||||||
|
transformResponse: (raw: RawSettings) => toSettings(raw),
|
||||||
|
invalidatesTags: ['Settings'],
|
||||||
|
}),
|
||||||
|
getScrobbling: build.query<ScrobblingConfig, void>({
|
||||||
|
query: () => '/settings/scrobbling',
|
||||||
|
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
|
||||||
|
providesTags: ['Settings'],
|
||||||
|
}),
|
||||||
|
updateScrobbling: build.mutation<
|
||||||
|
ScrobblingConfig,
|
||||||
|
{
|
||||||
|
enabled: boolean;
|
||||||
|
provider: ScrobbleProvider | null;
|
||||||
|
username: string | null;
|
||||||
|
// Write-only: send only when the user typed a new key; omit to keep the
|
||||||
|
// stored one (the server never returns it).
|
||||||
|
sessionKey?: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
query: ({ enabled, provider, username, sessionKey }) => ({
|
||||||
|
url: '/settings/scrobbling',
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
enabled,
|
||||||
|
provider,
|
||||||
|
username,
|
||||||
|
session_key: sessionKey,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
|
||||||
|
invalidatesTags: ['Settings'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetSettingsQuery,
|
||||||
|
useUpdateSettingsMutation,
|
||||||
|
useGetScrobblingQuery,
|
||||||
|
useUpdateScrobblingMutation,
|
||||||
|
} = settingsApi;
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import { toStorageStats, type RawStorageStats } from '../mappers';
|
import {
|
||||||
import type { StorageStats } from '../types';
|
toStorageStats,
|
||||||
|
toPage,
|
||||||
|
toTrack,
|
||||||
|
type RawStorageStats,
|
||||||
|
type RawPaged,
|
||||||
|
type RawTrack,
|
||||||
|
} from '../mappers';
|
||||||
|
import type {
|
||||||
|
StorageStats,
|
||||||
|
DuplicateGroup,
|
||||||
|
PaginatedResponse,
|
||||||
|
Track,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
||||||
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
// routes back the Storage Maintenance screen: `/duplicates`, `/broken`,
|
||||||
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
// `/missing-metadata` (read) and `POST /cleanup` (admin, enqueues the worker).
|
||||||
|
|
||||||
|
interface RawDuplicateGroup {
|
||||||
|
fingerprint: string;
|
||||||
|
tracks: RawTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paging {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const storageApi = api.injectEndpoints({
|
export const storageApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
@@ -13,16 +35,32 @@ export const storageApi = api.injectEndpoints({
|
|||||||
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||||
providesTags: ['Storage'],
|
providesTags: ['Storage'],
|
||||||
}),
|
}),
|
||||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
getDuplicates: build.query<DuplicateGroup[], void>({
|
||||||
query: () => ({ url: '/storage/scan', method: 'POST' }),
|
query: () => '/storage/duplicates',
|
||||||
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
|
transformResponse: (raw: RawDuplicateGroup[]) =>
|
||||||
|
raw.map((g) => ({
|
||||||
|
fingerprint: g.fingerprint,
|
||||||
|
tracks: g.tracks.map(toTrack),
|
||||||
|
})),
|
||||||
|
providesTags: ['Storage', 'Track'],
|
||||||
}),
|
}),
|
||||||
deleteTrackFile: build.mutation<void, string>({
|
getBrokenFiles: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||||
query: (trackId) => ({
|
query: (p) => ({ url: '/storage/broken', params: p ?? {} }),
|
||||||
url: `/storage/tracks/${trackId}`,
|
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||||
method: 'DELETE',
|
providesTags: ['Storage', 'Track'],
|
||||||
|
}),
|
||||||
|
getMissingMetadata: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||||
|
query: (p) => ({ url: '/storage/missing-metadata', params: p ?? {} }),
|
||||||
|
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||||
|
providesTags: ['Storage', 'Track'],
|
||||||
|
}),
|
||||||
|
runCleanup: build.mutation<{ status: string; jobId: string }, void>({
|
||||||
|
query: () => ({ url: '/storage/cleanup', method: 'POST' }),
|
||||||
|
transformResponse: (raw: { status: string; job_id: string }) => ({
|
||||||
|
status: raw.status,
|
||||||
|
jobId: raw.job_id,
|
||||||
}),
|
}),
|
||||||
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
|
invalidatesTags: ['Storage', 'Track'],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
@@ -30,6 +68,8 @@ export const storageApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetStorageStatsQuery,
|
useGetStorageStatsQuery,
|
||||||
useScanStorageMutation,
|
useGetDuplicatesQuery,
|
||||||
useDeleteTrackFileMutation,
|
useGetBrokenFilesQuery,
|
||||||
|
useGetMissingMetadataQuery,
|
||||||
|
useRunCleanupMutation,
|
||||||
} = storageApi;
|
} = storageApi;
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import { getApiBaseUrl } from '../../config/runtime-config';
|
|||||||
* param because `<audio>` can't send an `Authorization` header; the backend
|
* param because `<audio>` can't send an `Authorization` header; the backend
|
||||||
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
||||||
*/
|
*/
|
||||||
export function getStreamUrl(trackId: string, token: string): string {
|
export function getStreamUrl(
|
||||||
|
trackId: string,
|
||||||
|
token: string,
|
||||||
|
quality?: string,
|
||||||
|
): string {
|
||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
|
const q =
|
||||||
|
quality && quality !== 'original'
|
||||||
|
? `&quality=${encodeURIComponent(quality)}`
|
||||||
|
: '';
|
||||||
|
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}${q}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const api = createApi({
|
|||||||
'Like',
|
'Like',
|
||||||
'User',
|
'User',
|
||||||
'Storage',
|
'Storage',
|
||||||
|
'Settings',
|
||||||
],
|
],
|
||||||
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
||||||
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const toUser = (r: RawUser): User => ({
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
// MVP role model: superuser → admin, everyone else → user.
|
// MVP role model: superuser → admin, everyone else → user.
|
||||||
role: r.is_superuser ? 'admin' : 'user',
|
role: r.is_superuser ? 'admin' : 'user',
|
||||||
|
isActive: r.is_active,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ export interface UploadResponse {
|
|||||||
already_exists: boolean;
|
already_exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One backing-dependency probe result (GET /admin/services). `skipped` = the
|
||||||
|
* dependency is optional and not configured (e.g. ML with no ML_SERVICE_URL). */
|
||||||
|
export type ServiceStatus = 'ok' | 'down' | 'skipped';
|
||||||
|
|
||||||
|
export interface ServicesStatus {
|
||||||
|
database: ServiceStatus;
|
||||||
|
redis: ServiceStatus;
|
||||||
|
ml: ServiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageFormatBreakdown {
|
export interface StorageFormatBreakdown {
|
||||||
fileFormat: string;
|
fileFormat: string;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
@@ -176,11 +186,43 @@ export interface StorageStats {
|
|||||||
disk?: StorageDiskUsage;
|
disk?: StorageDiskUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
fingerprint: string;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One radio/similarity pick with its reason code (§6.5). `reason`: `ml` |
|
||||||
|
* `similar` | `from_likes` | `discover` — localized client-side. */
|
||||||
|
export interface RadioPick {
|
||||||
|
track: Track;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radio / similar response: where the picks came from + the picks. `source`:
|
||||||
|
* `ml` (recommender) or `metadata` (fallback heuristics). */
|
||||||
|
export interface RadioResult {
|
||||||
|
source: string;
|
||||||
|
picks: RadioPick[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached lyrics for a track (§6.7). `not_found` is a normal state, not an
|
||||||
|
* error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */
|
||||||
|
export interface Lyrics {
|
||||||
|
trackId: string;
|
||||||
|
status: 'found' | 'not_found' | 'pending';
|
||||||
|
source?: string;
|
||||||
|
synced?: string;
|
||||||
|
plain?: string;
|
||||||
|
syncedAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
role: 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastActiveAt?: string;
|
lastActiveAt?: string;
|
||||||
}
|
}
|
||||||
@@ -258,3 +300,24 @@ export interface MetadataEdit {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
trackNumber?: number;
|
trackNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- user settings (`/settings`) ---------------------------------------------
|
||||||
|
export type ThemePref = 'system' | 'light' | 'dark';
|
||||||
|
export type StreamQuality = 'original' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
/** General per-user preferences from `GET /settings`. */
|
||||||
|
export interface AppSettings {
|
||||||
|
theme: ThemePref;
|
||||||
|
streamQuality: StreamQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScrobbleProvider = 'lastfm' | 'listenbrainz';
|
||||||
|
|
||||||
|
/** Scrobbling config from `GET /settings/scrobbling`. The session key is
|
||||||
|
* write-only server-side, so it never appears here — only `configured`. */
|
||||||
|
export interface ScrobblingConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
provider: ScrobbleProvider | null;
|
||||||
|
username: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Outlet } from 'react-router';
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||||
|
import { NowPlaying } from '../player/NowPlaying';
|
||||||
import { QueuePanel } from '../player/QueuePanel';
|
import { QueuePanel } from '../player/QueuePanel';
|
||||||
|
import { RadioController } from '../player/RadioController';
|
||||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||||
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
||||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
@@ -37,6 +39,8 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
<PersistentPlayer />
|
<PersistentPlayer />
|
||||||
|
<NowPlaying />
|
||||||
|
<RadioController />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Spinner } from '@olly/modern-sk';
|
||||||
|
import { useGetLyricsQuery, useRefetchLyricsMutation } from '../../api/endpoints/lyrics';
|
||||||
|
import { parseLrc, activeLineIndex } from '../../lib/lrc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lyrics tab inside Now Playing. Synced mode highlights the active LRC line by
|
||||||
|
* playback position (auto-scrolling; tap a line to seek to its timecode); plain
|
||||||
|
* mode is a static scroll. Missing lyrics is a normal state with a re-fetch.
|
||||||
|
*/
|
||||||
|
export function LyricsPanel({
|
||||||
|
trackId,
|
||||||
|
position,
|
||||||
|
onSeek,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
position: number;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetLyricsQuery(trackId);
|
||||||
|
const [refetchLyrics, { isLoading: isRefetching }] =
|
||||||
|
useRefetchLyricsMutation();
|
||||||
|
|
||||||
|
const lines = data?.synced ? parseLrc(data.synced) : [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Spinner />
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<p style={{ color: 'var(--color-text-2)' }}>{t('common.error')}</p>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void refetch()}>
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLyrics =
|
||||||
|
data?.status === 'found' && (lines.length > 0 || Boolean(data.plain));
|
||||||
|
|
||||||
|
if (!hasLyrics) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||||
|
{t('lyrics.none')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isRefetching}
|
||||||
|
onClick={() => void refetchLyrics(trackId)}
|
||||||
|
>
|
||||||
|
{isRefetching ? <Spinner size="sm" /> : t('lyrics.refetch')}
|
||||||
|
</Button>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length > 0) {
|
||||||
|
return (
|
||||||
|
<SyncedLyrics lines={lines} position={position} onSeek={onSeek} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain fallback — no timestamps to sync against.
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1rem 0', maxWidth: 640, margin: '0 auto' }}>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: '1rem',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
margin: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.plain}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncedLyrics({
|
||||||
|
lines,
|
||||||
|
position,
|
||||||
|
onSeek,
|
||||||
|
}: {
|
||||||
|
lines: { time: number; text: string }[];
|
||||||
|
position: number;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
}) {
|
||||||
|
const activeIndex = activeLineIndex(lines, position);
|
||||||
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
// Keep the active line centred as playback advances.
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '30vh 0',
|
||||||
|
maxWidth: 640,
|
||||||
|
margin: '0 auto',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const active = i === activeIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${line.time}-${i}`}
|
||||||
|
ref={active ? activeRef : undefined}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSeek(line.time)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'inherit',
|
||||||
|
padding: '0.15rem 0.5rem',
|
||||||
|
fontSize: active ? '1.4rem' : '1.1rem',
|
||||||
|
fontWeight: active ? 700 : 500,
|
||||||
|
color: active ? 'var(--color-text-1)' : 'var(--color-text-3)',
|
||||||
|
opacity: active ? 1 : 0.6,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.text || '♪'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Centered({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Slider, SegmentedControl, IconButton } from '@olly/modern-sk';
|
||||||
|
import { Icon } from '../common/Icon';
|
||||||
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { pause, resume, setNowPlayingOpen } from '../../store/slices/player';
|
||||||
|
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
|
type Tab = 'artwork' | 'lyrics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen Now Playing overlay (§A1 / §A11). Expands from the persistent
|
||||||
|
* player: big artwork or a synced Lyrics tab, plus transport + seek. Mounted in
|
||||||
|
* AppShell; visibility is driven by `player.isNowPlayingOpen`.
|
||||||
|
*/
|
||||||
|
export function NowPlaying() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { seek, playNext, playPrev } = useAudioPlayer();
|
||||||
|
const player = useAppSelector((s) => s.player);
|
||||||
|
const queue = useAppSelector((s) => s.queue);
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
const [tab, setTab] = useState<Tab>('artwork');
|
||||||
|
|
||||||
|
const currentEntry = queue.entries[queue.currentIndex];
|
||||||
|
const current = useResolvedQueueEntry(currentEntry);
|
||||||
|
|
||||||
|
if (!player.isNowPlayingOpen || !currentEntry || !current) return null;
|
||||||
|
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(currentEntry.albumArtUrl) ??
|
||||||
|
(token && current.hasCover
|
||||||
|
? getTrackCoverUrl(current.trackId, token, true)
|
||||||
|
: undefined);
|
||||||
|
const seedLabel = current.albumTitle ?? current.title ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 60,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '1rem 1.5rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => dispatch(setNowPlayingOpen(false))}
|
||||||
|
aria-label={t('nowPlaying.collapse')}
|
||||||
|
>
|
||||||
|
⌄
|
||||||
|
</IconButton>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<SegmentedControl
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as Tab)}
|
||||||
|
items={[
|
||||||
|
{ value: 'artwork', label: t('nowPlaying.artwork') },
|
||||||
|
{ value: 'lyrics', label: t('nowPlaying.lyrics') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '0 1.5rem' }}>
|
||||||
|
{tab === 'artwork' ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArtTile
|
||||||
|
seed={seedLabel}
|
||||||
|
size={320}
|
||||||
|
label={seedLabel}
|
||||||
|
src={artUrl}
|
||||||
|
/>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 700 }}>
|
||||||
|
{current.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '1rem', color: 'var(--color-text-2)' }}>
|
||||||
|
{current.artistName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LyricsPanel
|
||||||
|
trackId={current.trackId}
|
||||||
|
position={player.position}
|
||||||
|
onSeek={seek}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '1rem 1.5rem 1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
maxWidth: 720,
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="pl-time">{formatDuration(player.position * 1000)}</span>
|
||||||
|
<Slider
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
min={0}
|
||||||
|
max={player.duration || 1}
|
||||||
|
step={1}
|
||||||
|
value={[player.position]}
|
||||||
|
onValueChange={([v]) => seek(v)}
|
||||||
|
aria-label={t('player.play')}
|
||||||
|
/>
|
||||||
|
<span className="pl-time">{formatDuration(player.duration * 1000)}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pl-tbtn"
|
||||||
|
onClick={playPrev}
|
||||||
|
title={t('player.previous')}
|
||||||
|
>
|
||||||
|
<Icon name="skip-back" fill />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pl-play"
|
||||||
|
onClick={() =>
|
||||||
|
player.isPlaying ? dispatch(pause()) : dispatch(resume())
|
||||||
|
}
|
||||||
|
title={player.isPlaying ? t('player.pause') : t('player.play')}
|
||||||
|
>
|
||||||
|
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pl-tbtn"
|
||||||
|
onClick={playNext}
|
||||||
|
title={t('player.next')}
|
||||||
|
>
|
||||||
|
<Icon name="skip-forward" fill />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
setVolume,
|
setVolume,
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
|
toggleNowPlaying,
|
||||||
} from '../../store/slices/player';
|
} from '../../store/slices/player';
|
||||||
import { openTrackInfo } from '../../store/slices/ui';
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
@@ -145,6 +146,14 @@ export function PersistentPlayer() {
|
|||||||
>
|
>
|
||||||
<Icon name="queue" />
|
<Icon name="queue" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn sm"
|
||||||
|
onClick={() => dispatch(toggleNowPlaying())}
|
||||||
|
title={t('nowPlaying.expand')}
|
||||||
|
>
|
||||||
|
<Icon name="vinyl-record" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
clearQueue,
|
clearQueue,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
toggleLoop,
|
toggleLoop,
|
||||||
|
setExploration,
|
||||||
type QueueEntry,
|
type QueueEntry,
|
||||||
} from '../../store/slices/queue';
|
} from '../../store/slices/queue';
|
||||||
import { toggleQueue } from '../../store/slices/player';
|
import { toggleQueue } from '../../store/slices/player';
|
||||||
@@ -148,7 +149,8 @@ export function QueuePanel() {
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
defaultValue={[42]}
|
value={[Math.round((queue.radio?.exploration ?? 0.25) * 100)]}
|
||||||
|
onValueChange={([v]) => dispatch(setExploration(v / 100))}
|
||||||
aria-label="Exploration"
|
aria-label="Exploration"
|
||||||
/>
|
/>
|
||||||
<span className="lab">{t('queue.new')}</span>
|
<span className="lab">{t('queue.new')}</span>
|
||||||
@@ -277,6 +279,14 @@ function QueueRow({
|
|||||||
className="r"
|
className="r"
|
||||||
text={resolved?.artistName ?? entry.artistName}
|
text={resolved?.artistName ?? entry.artistName}
|
||||||
/>
|
/>
|
||||||
|
{entry.reason && (
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 11, color: 'var(--color-accent)' }}
|
||||||
|
title={t('queue.whyPlaying')}
|
||||||
|
>
|
||||||
|
{t(`queue.reason.${entry.reason}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useRadioNextMutation } from '../../api/endpoints/radio';
|
||||||
|
import { appendToQueue } from '../../store/slices/queue';
|
||||||
|
import { pickToEntry } from '../../lib/radio';
|
||||||
|
|
||||||
|
// Prefetch more radio tracks once the current position is within this many of
|
||||||
|
// the end of the queue.
|
||||||
|
const LOOKAHEAD = 3;
|
||||||
|
|
||||||
|
// Only the most recent tracks are sent as "don't repeat these" — bounding the
|
||||||
|
// request so a long radio session doesn't ship an ever-growing exclude list
|
||||||
|
// (and a huge NOT IN on the server). Recent-repeat avoidance is what matters.
|
||||||
|
const EXCLUDE_WINDOW = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless controller: while a radio drives the queue, top it up as it drains
|
||||||
|
* (§6.5 / B6 infinite feed). Mounted once in AppShell. A failed prefetch just
|
||||||
|
* stops the feed — playback of what's already queued is unaffected.
|
||||||
|
*/
|
||||||
|
export function RadioController() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const radio = useAppSelector((s) => s.queue.radio);
|
||||||
|
const currentIndex = useAppSelector((s) => s.queue.currentIndex);
|
||||||
|
const entries = useAppSelector((s) => s.queue.entries);
|
||||||
|
const [radioNext] = useRadioNextMutation();
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!radio) return;
|
||||||
|
const nearEnd = currentIndex >= entries.length - LOOKAHEAD;
|
||||||
|
if (!nearEnd || loadingRef.current) return;
|
||||||
|
|
||||||
|
loadingRef.current = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await radioNext({
|
||||||
|
seedTrackId: radio.seedTrackId ?? undefined,
|
||||||
|
fromLikes: radio.fromLikes,
|
||||||
|
exploration: radio.exploration,
|
||||||
|
count: 10,
|
||||||
|
excludeIds: entries.slice(-EXCLUDE_WINDOW).map((e) => e.trackId),
|
||||||
|
}).unwrap();
|
||||||
|
if (res.picks.length > 0) {
|
||||||
|
dispatch(appendToQueue(res.picks.map(pickToEntry)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort — leave the queue as-is
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [radio, currentIndex, entries, radioNext, dispatch]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useAppDispatch } from '../../hooks/useAppDispatch';
|
|||||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
import { openTrackInfo } from '../../store/slices/ui';
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
|
import { useStartRadio } from '../../hooks/useStartRadio';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,6 +33,7 @@ export function TrackContextMenu({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { start: startRadio } = useStartRadio();
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
@@ -75,6 +77,9 @@ export function TrackContextMenu({
|
|||||||
>
|
>
|
||||||
{t('track.menu.addToQueue')}
|
{t('track.menu.addToQueue')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={() => void startRadio({ seedTrackId: track.id })}>
|
||||||
|
{t('track.menu.startRadio')}
|
||||||
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
import {
|
||||||
|
Window,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
SegmentedControl,
|
||||||
|
Dialog,
|
||||||
|
AlertDialog,
|
||||||
|
Menu,
|
||||||
|
MenuTrigger,
|
||||||
|
MenuContent,
|
||||||
|
MenuItem,
|
||||||
|
IconButton,
|
||||||
|
Badge,
|
||||||
|
Callout,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
|
import { Icon } from '../../components/common/Icon';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
|
import {
|
||||||
|
useGetUsersQuery,
|
||||||
|
useGetUserQuery,
|
||||||
|
useCreateUserMutation,
|
||||||
|
useUpdateUserMutation,
|
||||||
|
useDeleteUserMutation,
|
||||||
|
useResetPasswordMutation,
|
||||||
|
useGetServicesQuery,
|
||||||
|
useGetSourcesQuery,
|
||||||
|
useReindexMutation,
|
||||||
|
} from '../../api/endpoints/admin';
|
||||||
|
import type { User, ServiceStatus } from '../../api/types';
|
||||||
|
|
||||||
function StubPanel({ title }: { title: string }) {
|
function StubPanel({ title }: { title: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -12,16 +46,430 @@ function StubPanel({ title }: { title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/admin/users` — user list (add/remove). Scaffold. */
|
/** Role + active-status badges shared by the list and the detail view. */
|
||||||
export function AdminUsers() {
|
function UserBadges({ user }: { user: User }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <StubPanel title={t('admin.tabs.users')} />;
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '0.375rem', alignItems: 'center' }}>
|
||||||
|
<Badge variant={user.role === 'admin' ? 'lime' : 'neutral'}>
|
||||||
|
{t(`admin.users.role.${user.role}`)}
|
||||||
|
</Badge>
|
||||||
|
{!user.isActive && (
|
||||||
|
<Badge variant="ember">{t('admin.users.status.blocked')}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
|
/** `/admin/users` — user list with add / role toggle / (de)activate / reset password. */
|
||||||
|
export function AdminUsers() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: users, isLoading, isError, refetch } = useGetUsersQuery();
|
||||||
|
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||||
|
const [createError, setCreateError] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
setRole('user');
|
||||||
|
setCreateError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = username.trim();
|
||||||
|
if (!name || !password) return;
|
||||||
|
setCreateError(false);
|
||||||
|
try {
|
||||||
|
await createUser({ username: name, password, role }).unwrap();
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetForm();
|
||||||
|
} catch {
|
||||||
|
setCreateError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Window title={t('admin.tabs.users')}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetForm();
|
||||||
|
}}
|
||||||
|
title={t('admin.users.newDialog.title')}
|
||||||
|
trigger={
|
||||||
|
<Button variant="primary">
|
||||||
|
<Icon name="plus" /> {t('admin.users.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
||||||
|
{t('admin.users.newDialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!username.trim() || !password || isCreating}
|
||||||
|
onClick={(e) => void handleCreate(e)}
|
||||||
|
>
|
||||||
|
{t('admin.users.newDialog.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleCreate(e)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t('admin.users.newDialog.username')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t('admin.users.newDialog.password')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={role}
|
||||||
|
onValueChange={(v) => setRole(v === 'admin' ? 'admin' : 'user')}
|
||||||
|
items={[
|
||||||
|
{ value: 'user', label: t('admin.users.role.user') },
|
||||||
|
{ value: 'admin', label: t('admin.users.role.admin') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{createError && (
|
||||||
|
<Callout variant="danger">
|
||||||
|
{t('admin.users.newDialog.error')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <LoadingSkeleton rows={5} height={56} />}
|
||||||
|
{isError && <ErrorState onRetry={refetch} />}
|
||||||
|
{!isLoading && !isError && (users?.length ?? 0) === 0 && (
|
||||||
|
<EmptyState icon="☺" title={t('admin.users.empty')} />
|
||||||
|
)}
|
||||||
|
{!isLoading && !isError && users && users.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{users.map((user) => (
|
||||||
|
<UserRow key={user.id} user={user} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Window>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the user list: identity + badges + actions menu. */
|
||||||
|
function UserRow({ user }: { user: User }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user: currentUser } = usePermissions();
|
||||||
|
const isSelf = currentUser?.id === user.id;
|
||||||
|
|
||||||
|
const [updateUser] = useUpdateUserMutation();
|
||||||
|
const [deleteUser] = useDeleteUserMutation();
|
||||||
|
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggleRole = () =>
|
||||||
|
void updateUser({
|
||||||
|
id: user.id,
|
||||||
|
role: user.role === 'admin' ? 'user' : 'admin',
|
||||||
|
});
|
||||||
|
const toggleActive = () =>
|
||||||
|
void updateUser({ id: user.id, isActive: !user.isActive });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate(`/admin/users/${user.id}`)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: 'left',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
font: 'inherit',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
{isSelf && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.users.you')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('admin.users.added', {
|
||||||
|
date: new Date(user.createdAt).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<UserBadges user={user} />
|
||||||
|
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('admin.users.menu.options')}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</IconButton>
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem onSelect={toggleRole}>
|
||||||
|
{user.role === 'admin'
|
||||||
|
? t('admin.users.menu.makeUser')
|
||||||
|
: t('admin.users.menu.makeAdmin')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={() => setResetOpen(true)}>
|
||||||
|
{t('admin.users.menu.resetPassword')}
|
||||||
|
</MenuItem>
|
||||||
|
{/* Guard against locking yourself out of the instance. */}
|
||||||
|
{!isSelf && (
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
user.isActive ? setDeactivateOpen(true) : toggleActive()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.isActive
|
||||||
|
? t('admin.users.menu.deactivate')
|
||||||
|
: t('admin.users.menu.activate')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<ResetPasswordDialog
|
||||||
|
user={user}
|
||||||
|
open={resetOpen}
|
||||||
|
onOpenChange={setResetOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={deactivateOpen}
|
||||||
|
onOpenChange={setDeactivateOpen}
|
||||||
|
title={t('admin.users.deactivateDialog.title')}
|
||||||
|
description={t('admin.users.deactivateDialog.description', {
|
||||||
|
name: user.username,
|
||||||
|
})}
|
||||||
|
cancelLabel={t('admin.users.deactivateDialog.cancel')}
|
||||||
|
actionLabel={t('admin.users.deactivateDialog.confirm')}
|
||||||
|
destructive
|
||||||
|
onAction={() => void deleteUser(user.id)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset-password dialog: admin sets a new password for the account. */
|
||||||
|
function ResetPasswordDialog({
|
||||||
|
user,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
user: User;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [resetPassword, { isLoading }] = useResetPasswordMutation();
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!value) return;
|
||||||
|
await resetPassword({ id: user.id, newPassword: value }).unwrap();
|
||||||
|
setValue('');
|
||||||
|
setDone(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
onOpenChange(o);
|
||||||
|
if (!o) {
|
||||||
|
setValue('');
|
||||||
|
setDone(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t('admin.users.resetDialog.title', { name: user.username })}
|
||||||
|
footer={
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
{t('admin.users.resetDialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!value || isLoading}
|
||||||
|
onClick={(e) => void handleSubmit(e)}
|
||||||
|
>
|
||||||
|
{t('admin.users.resetDialog.submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{done ? (
|
||||||
|
<Callout variant="success">{t('admin.users.resetDialog.done')}</Callout>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)}>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={t('admin.users.resetDialog.newPassword')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/admin/users/:userId` — per-user detail: identity, role toggle, reset, status. */
|
||||||
export function AdminUserDetail() {
|
export function AdminUserDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <StubPanel title={t('admin.userDetail')} />;
|
const navigate = useNavigate();
|
||||||
|
const { userId = '' } = useParams();
|
||||||
|
const { user: currentUser } = usePermissions();
|
||||||
|
|
||||||
|
const { data: user, isLoading, isError, refetch } = useGetUserQuery(userId, {
|
||||||
|
skip: !userId,
|
||||||
|
});
|
||||||
|
const [updateUser] = useUpdateUserMutation();
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
|
||||||
|
const isSelf = currentUser?.id === user?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Window title={t('admin.userDetail')}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void navigate('/admin/users')}>
|
||||||
|
← {t('common.back')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||||
|
{isError && <ErrorState onRetry={refetch} />}
|
||||||
|
{user && (
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>{user.username}</h2>
|
||||||
|
<UserBadges user={user} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('admin.users.added', {
|
||||||
|
date: new Date(user.createdAt).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
void updateUser({
|
||||||
|
id: user.id,
|
||||||
|
role: user.role === 'admin' ? 'user' : 'admin',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.role === 'admin'
|
||||||
|
? t('admin.users.menu.makeUser')
|
||||||
|
: t('admin.users.menu.makeAdmin')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setResetOpen(true)}>
|
||||||
|
{t('admin.users.menu.resetPassword')}
|
||||||
|
</Button>
|
||||||
|
{!isSelf && (
|
||||||
|
<Button
|
||||||
|
variant={user.isActive ? 'ember' : 'ghost'}
|
||||||
|
onClick={() =>
|
||||||
|
void updateUser({ id: user.id, isActive: !user.isActive })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.isActive
|
||||||
|
? t('admin.users.menu.deactivate')
|
||||||
|
: t('admin.users.menu.activate')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResetPasswordDialog
|
||||||
|
user={user}
|
||||||
|
open={resetOpen}
|
||||||
|
onOpenChange={setResetOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Window>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
|
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
|
||||||
@@ -30,8 +478,174 @@ export function AdminSources() {
|
|||||||
return <StubPanel title={t('admin.tabs.sources')} />;
|
return <StubPanel title={t('admin.tabs.sources')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
|
const SERVICE_VARIANT: Record<ServiceStatus, 'lime' | 'ember' | 'neutral'> = {
|
||||||
|
ok: 'lime',
|
||||||
|
down: 'ember',
|
||||||
|
skipped: 'neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `/admin/instance` — backing-service health, source status, library reindex. */
|
||||||
export function AdminInstance() {
|
export function AdminInstance() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <StubPanel title={t('admin.tabs.instance')} />;
|
const {
|
||||||
|
data: servicesData,
|
||||||
|
isLoading: servicesLoading,
|
||||||
|
isError: servicesError,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = useGetServicesQuery();
|
||||||
|
const {
|
||||||
|
data: sourcesData,
|
||||||
|
isLoading: sourcesLoading,
|
||||||
|
isError: sourcesError,
|
||||||
|
refetch: refetchSources,
|
||||||
|
} = useGetSourcesQuery();
|
||||||
|
const [reindex, { isLoading: isReindexing }] = useReindexMutation();
|
||||||
|
const [reindexMsg, setReindexMsg] = useState<
|
||||||
|
{ kind: 'success' | 'danger'; text: string } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const handleReindex = async () => {
|
||||||
|
setReindexMsg(null);
|
||||||
|
try {
|
||||||
|
const jobs = await reindex().unwrap();
|
||||||
|
setReindexMsg({
|
||||||
|
kind: 'success',
|
||||||
|
text: t('admin.instance.reindex.enqueued', { count: jobs.length }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 400 when no indexable source is configured (see admin.py::trigger_reindex).
|
||||||
|
setReindexMsg({
|
||||||
|
kind: 'danger',
|
||||||
|
text: t('admin.instance.reindex.noSource'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Window title={t('admin.tabs.instance')}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
{/* ── Services ─────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem', fontSize: '0.95rem' }}>
|
||||||
|
{t('admin.instance.services.title')}
|
||||||
|
</h3>
|
||||||
|
{servicesLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||||
|
{servicesError && <ErrorState onRetry={refetchServices} />}
|
||||||
|
{servicesData && (
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{(['database', 'redis', 'ml'] as const).map((key) => {
|
||||||
|
const status = servicesData[key];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
padding: '0.625rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
{t(`admin.instance.services.${key}`)}
|
||||||
|
</span>
|
||||||
|
<Badge variant={SERVICE_VARIANT[status]} dot>
|
||||||
|
{t(`admin.instance.status.${status}`)}
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Sources (read-only; configured via env) ──────────────── */}
|
||||||
|
<section>
|
||||||
|
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.95rem' }}>
|
||||||
|
{t('admin.instance.sources.title')}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 0.5rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.instance.sources.envNote')}
|
||||||
|
</p>
|
||||||
|
{sourcesLoading && <LoadingSkeleton rows={2} height={40} />}
|
||||||
|
{sourcesError && <ErrorState onRetry={refetchSources} />}
|
||||||
|
{sourcesData && sourcesData.length === 0 && (
|
||||||
|
<EmptyState icon="⊘" title={t('admin.instance.sources.empty')} />
|
||||||
|
)}
|
||||||
|
{sourcesData && sourcesData.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{sourcesData.map((s) => (
|
||||||
|
<Card
|
||||||
|
key={s.name}
|
||||||
|
style={{
|
||||||
|
padding: '0.625rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
{s.label}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.kind}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant={s.available ? 'lime' : 'ember'} dot>
|
||||||
|
{t(
|
||||||
|
s.available
|
||||||
|
? 'admin.instance.sources.available'
|
||||||
|
: 'admin.instance.sources.unavailable',
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Reindex ──────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.95rem' }}>
|
||||||
|
{t('admin.instance.reindex.title')}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 0.75rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.instance.reindex.description')}
|
||||||
|
</p>
|
||||||
|
{reindexMsg && (
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<Callout variant={reindexMsg.kind}>{reindexMsg.text}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={isReindexing}
|
||||||
|
onClick={() => void handleReindex()}
|
||||||
|
>
|
||||||
|
{t('admin.instance.reindex.button')}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Window>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
TabsContent,
|
TabsContent,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
TextField,
|
|
||||||
Callout,
|
Callout,
|
||||||
SearchField,
|
SearchField,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
IconButton,
|
IconButton,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
Spinner,
|
Spinner,
|
||||||
TextField,
|
TextField,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
@@ -16,11 +17,17 @@ import {
|
|||||||
useEnrichTrackMutation,
|
useEnrichTrackMutation,
|
||||||
useGetTrackQuery,
|
useGetTrackQuery,
|
||||||
useLazyGetMetadataMatchesQuery,
|
useLazyGetMetadataMatchesQuery,
|
||||||
|
useOptimizeTrackMutation,
|
||||||
} from '../../api/endpoints/library';
|
} from '../../api/endpoints/library';
|
||||||
import type { MetadataMatch } from '../../api/types';
|
import {
|
||||||
|
useGetBrokenFilesQuery,
|
||||||
|
useGetMissingMetadataQuery,
|
||||||
|
} from '../../api/endpoints/storage';
|
||||||
|
import type { MetadataEdit, MetadataMatch, Track } from '../../api/types';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
||||||
@@ -58,15 +65,31 @@ function fieldStyle(): React.CSSProperties {
|
|||||||
return { width: '100%' };
|
return { width: '100%' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap how many bulk metadata ops run at once — a large selection would otherwise
|
||||||
|
// fire hundreds of simultaneous requests (each also spins up a worker enqueue).
|
||||||
|
const BULK_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
async function settleInBatches<T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
op: (item: T) => Promise<unknown>,
|
||||||
|
): Promise<PromiseSettledResult<unknown>[]> {
|
||||||
|
const results: PromiseSettledResult<unknown>[] = [];
|
||||||
|
for (let i = 0; i < items.length; i += limit) {
|
||||||
|
const batch = items.slice(i, i + limit);
|
||||||
|
results.push(...(await Promise.allSettled(batch.map(op))));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||||
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
|
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
||||||
|
* metadata-status bucket (§A6 → A7 flow): bulk re-enrich + apply common fields.
|
||||||
*/
|
*/
|
||||||
export function MetadataEditorPage({ batch = false }: Props) {
|
export function MetadataEditorPage({ batch = false }: Props) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (batch) {
|
if (batch) {
|
||||||
return <Placeholder title={t('pages.metadataBatch')} />;
|
return <BatchEditor />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SingleTrackEditor />;
|
return <SingleTrackEditor />;
|
||||||
@@ -413,12 +436,67 @@ function SingleTrackEditor() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<OptimizeCard trackId={trackId} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enqueue a transcode to a cached Opus + HLS rendition (§6.6 / A6). */
|
||||||
|
function OptimizeCard({ trackId }: { trackId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [optimize, { isLoading, isSuccess, isError }] =
|
||||||
|
useOptimizeTrackMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataEditor.optimize.title')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('metadataEditor.optimize.hint')}
|
||||||
|
</div>
|
||||||
|
{isSuccess && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<Callout variant="info">
|
||||||
|
{t('metadataEditor.optimize.enqueued')}
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<Callout variant="danger">{t('common.error')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => void optimize({ trackId })}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.optimize.button')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MatchRow({
|
function MatchRow({
|
||||||
match,
|
match,
|
||||||
onUse,
|
onUse,
|
||||||
@@ -589,3 +667,319 @@ function DiffView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Batch editor (§A7 / §A6 cross-flow) ─────────────────────────────────────
|
||||||
|
|
||||||
|
interface BatchForm {
|
||||||
|
artistName: string;
|
||||||
|
albumTitle: string;
|
||||||
|
year: string;
|
||||||
|
genre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_BATCH: BatchForm = {
|
||||||
|
artistName: '',
|
||||||
|
albumTitle: '',
|
||||||
|
year: '',
|
||||||
|
genre: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchStatus = 'pending' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/metadata/batch?status=pending|failed` — bulk metadata over a status bucket
|
||||||
|
* (tracks awaiting enrichment, or ones whose enrichment failed). Two batch
|
||||||
|
* actions: re-run auto-enrichment, or stamp common fields onto the selection.
|
||||||
|
*/
|
||||||
|
function BatchEditor() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const status: BatchStatus =
|
||||||
|
params.get('status') === 'failed' ? 'failed' : 'pending';
|
||||||
|
|
||||||
|
const missing = useGetMissingMetadataQuery(
|
||||||
|
{ limit: 200 },
|
||||||
|
{ skip: status !== 'pending' },
|
||||||
|
);
|
||||||
|
const broken = useGetBrokenFilesQuery(
|
||||||
|
{ limit: 200 },
|
||||||
|
{ skip: status !== 'failed' },
|
||||||
|
);
|
||||||
|
const active = status === 'failed' ? broken : missing;
|
||||||
|
const items: Track[] = active.data?.items ?? [];
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [seededFor, setSeededFor] = useState<BatchStatus | null>(null);
|
||||||
|
|
||||||
|
// Seed the selection (all) the first time each bucket's data arrives.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active.data && seededFor !== status) {
|
||||||
|
setSelected(new Set(active.data.items.map((tr) => tr.id)));
|
||||||
|
setSeededFor(status);
|
||||||
|
}
|
||||||
|
}, [active.data, seededFor, status]);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<BatchForm>(EMPTY_BATCH);
|
||||||
|
const [enrichTrack] = useEnrichTrackMutation();
|
||||||
|
const [applyMetadata] = useApplyMetadataMutation();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ ok: number; fail: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runBulk = async (op: (id: string) => Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setResult(null);
|
||||||
|
const ids = [...selected];
|
||||||
|
const outcomes = await settleInBatches(ids, BULK_CONCURRENCY, op);
|
||||||
|
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
||||||
|
setResult({ ok, fail: ids.length - ok });
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reEnrich = () => void runBulk((id) => enrichTrack(id).unwrap());
|
||||||
|
const applyCommon = () => {
|
||||||
|
const edit: MetadataEdit = {
|
||||||
|
artistName: form.artistName.trim() || undefined,
|
||||||
|
albumTitle: form.albumTitle.trim() || undefined,
|
||||||
|
year: form.year.trim() ? Number(form.year) : undefined,
|
||||||
|
genre: form.genre.trim() || undefined,
|
||||||
|
};
|
||||||
|
void runBulk((id) => applyMetadata({ trackId: id, edit }).unwrap());
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (key: keyof BatchForm) => (value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
const hasCommonEdits = Object.values(form).some((v) => v.trim() !== '');
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</IconButton>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('pages.metadataBatch')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.125rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('metadataBatch.selectedCount', {
|
||||||
|
selected: selectedCount,
|
||||||
|
total: items.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setParams({ status: v })}
|
||||||
|
items={[
|
||||||
|
{ value: 'pending', label: t('metadataBatch.status.pending') },
|
||||||
|
{ value: 'failed', label: t('metadataBatch.status.failed') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
maxWidth: 760,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result && (
|
||||||
|
<Callout variant={result.fail === 0 ? 'success' : 'danger'}>
|
||||||
|
{t('metadataBatch.result', {
|
||||||
|
ok: result.ok,
|
||||||
|
fail: result.fail,
|
||||||
|
})}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataBatch.reEnrich.title')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('metadataBatch.reEnrich.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={reEnrich}
|
||||||
|
disabled={busy || selectedCount === 0}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataBatch.reEnrich.button')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--color-border)' }} />
|
||||||
|
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataBatch.common.title')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
|
||||||
|
>
|
||||||
|
{t('metadataBatch.common.hint')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.artist')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.artistName}
|
||||||
|
onChange={(e) => updateField('artistName')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.album')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.albumTitle}
|
||||||
|
onChange={(e) => updateField('albumTitle')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.year')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
type="number"
|
||||||
|
value={form.year}
|
||||||
|
onChange={(e) => updateField('year')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.genre')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.genre}
|
||||||
|
onChange={(e) => updateField('genre')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={applyCommon}
|
||||||
|
disabled={busy || selectedCount === 0 || !hasCommonEdits}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataBatch.common.apply')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{active.isLoading && <LoadingSkeleton rows={6} height={56} />}
|
||||||
|
{active.isError && <ErrorState onRetry={() => active.refetch()} />}
|
||||||
|
{!active.isLoading && !active.isError && items.length === 0 && (
|
||||||
|
<EmptyState icon="✓" title={t('metadataBatch.empty')} />
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((track) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(track.id)}
|
||||||
|
onChange={() => toggle(track.id)}
|
||||||
|
aria-label={track.title}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<TrackRow track={track} showAlbum />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
|
import {
|
||||||
|
Window,
|
||||||
|
SegmentedControl,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Badge,
|
||||||
|
useTheme,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
|
import { logout } from '../../store/slices/auth';
|
||||||
|
import { getActiveInstance } from '../../config/instances';
|
||||||
|
import {
|
||||||
|
useGetSettingsQuery,
|
||||||
|
useUpdateSettingsMutation,
|
||||||
|
useGetScrobblingQuery,
|
||||||
|
useUpdateScrobblingMutation,
|
||||||
|
} from '../../api/endpoints/settings';
|
||||||
|
import type {
|
||||||
|
ScrobbleProvider,
|
||||||
|
StreamQuality,
|
||||||
|
} from '../../api/types';
|
||||||
|
|
||||||
/** Labelled settings row: caption on the left, control on the right. */
|
/** Labelled settings row: caption on the left, control on the right. */
|
||||||
function SettingRow({
|
function SettingRow({
|
||||||
@@ -27,6 +51,14 @@ function SettingRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: '0.375rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
};
|
||||||
|
|
||||||
/** `/settings/profile` — profile + app language + theme (all wired). */
|
/** `/settings/profile` — profile + app language + theme (all wired). */
|
||||||
export function ProfileSettings() {
|
export function ProfileSettings() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -66,38 +98,282 @@ export function ProfileSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */
|
const STREAM_QUALITIES: StreamQuality[] = ['original', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
|
/** `/settings/playback` — default stream quality (server-synced). */
|
||||||
export function PlaybackSettings() {
|
export function PlaybackSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetSettingsQuery();
|
||||||
|
const [updateSettings, { isLoading: isSaving }] = useUpdateSettingsMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Window title={t('settings.tabs.playback')}>
|
<Window title={t('settings.tabs.playback')}>
|
||||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
{isLoading && <LoadingSkeleton rows={1} height={40} />}
|
||||||
{t('common.comingSoon')}
|
{isError && (
|
||||||
</p>
|
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingRow label={t('settings.playback.streamQuality')}>
|
||||||
|
<SegmentedControl
|
||||||
|
value={data.streamQuality}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
void updateSettings({ streamQuality: v as StreamQuality })
|
||||||
|
}
|
||||||
|
items={STREAM_QUALITIES.map((q) => ({
|
||||||
|
value: q,
|
||||||
|
label: t(`settings.playback.quality.${q}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? t('settings.common.saving')
|
||||||
|
: t('settings.playback.streamQualityHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Window>
|
</Window>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
|
const PROVIDERS: ScrobbleProvider[] = ['lastfm', 'listenbrainz'];
|
||||||
|
|
||||||
|
/** `/settings/scrobbling` — Last.fm / ListenBrainz linking (server-synced). */
|
||||||
export function ScrobblingSettings() {
|
export function ScrobblingSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetScrobblingQuery();
|
||||||
|
const [updateScrobbling, { isLoading: isSaving }] =
|
||||||
|
useUpdateScrobblingMutation();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [provider, setProvider] = useState<ScrobbleProvider>('lastfm');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [sessionKey, setSessionKey] = useState('');
|
||||||
|
const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||||
|
|
||||||
|
// Seed the form from the server once it loads (and on refetch).
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
setProvider(data.provider ?? 'lastfm');
|
||||||
|
setUsername(data.username ?? '');
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setStatus('idle');
|
||||||
|
try {
|
||||||
|
await updateScrobbling({
|
||||||
|
enabled,
|
||||||
|
provider: enabled ? provider : null,
|
||||||
|
username: username.trim() || null,
|
||||||
|
sessionKey: sessionKey || undefined,
|
||||||
|
}).unwrap();
|
||||||
|
setSessionKey(''); // the key is write-only — don't keep it around
|
||||||
|
setStatus('saved');
|
||||||
|
} catch {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Window title={t('settings.tabs.scrobbling')}>
|
<Window title={t('settings.tabs.scrobbling')}>
|
||||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||||
{t('common.comingSoon')}
|
{isError && (
|
||||||
</p>
|
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.scrobbling.description')}
|
||||||
|
</p>
|
||||||
|
<Badge variant={data.configured ? 'lime' : 'neutral'}>
|
||||||
|
{data.configured
|
||||||
|
? t('settings.scrobbling.configured')
|
||||||
|
: t('settings.scrobbling.notConfigured')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingRow label={t('settings.scrobbling.enabled')}>
|
||||||
|
<SegmentedControl
|
||||||
|
value={enabled ? 'on' : 'off'}
|
||||||
|
onValueChange={(v) => setEnabled(v === 'on')}
|
||||||
|
items={[
|
||||||
|
{ value: 'on', label: t('settings.scrobbling.on') },
|
||||||
|
{ value: 'off', label: t('settings.scrobbling.off') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<>
|
||||||
|
<SettingRow label={t('settings.scrobbling.provider')}>
|
||||||
|
<SegmentedControl
|
||||||
|
value={provider}
|
||||||
|
onValueChange={(v) => setProvider(v as ScrobbleProvider)}
|
||||||
|
items={PROVIDERS.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: t(`settings.scrobbling.${p}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('settings.scrobbling.username')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t('settings.scrobbling.usernamePlaceholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('settings.scrobbling.sessionKey')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
value={sessionKey}
|
||||||
|
onChange={(e) => setSessionKey(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
data.configured
|
||||||
|
? t('settings.scrobbling.sessionKeyKeep')
|
||||||
|
: t('settings.scrobbling.sessionKeyPlaceholder')
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginTop: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.scrobbling.sessionKeyHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'saved' && (
|
||||||
|
<Callout variant="success">
|
||||||
|
{t('settings.scrobbling.saved')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<Callout variant="danger">{t('settings.scrobbling.error')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? t('settings.common.saving')
|
||||||
|
: t('settings.common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Window>
|
</Window>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/settings/instance` — switch/forget instance. Scaffold. */
|
/** `/settings/instance` — show the active instance; switch or sign out (both
|
||||||
|
* routes back to /connect, the full instance manager). */
|
||||||
export function InstanceSettings() {
|
export function InstanceSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const instance = getActiveInstance();
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
dispatch(logout());
|
||||||
|
void navigate('/connect');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Window title={t('settings.tabs.instance')}>
|
<Window title={t('settings.tabs.instance')}>
|
||||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
<div
|
||||||
{t('common.comingSoon')}
|
style={{
|
||||||
</p>
|
padding: '0.75rem 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span style={labelStyle}>{t('settings.instance.current')}</span>
|
||||||
|
{instance ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{instance.name}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{instance.baseUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: 0, color: 'var(--color-text-2)' }}>
|
||||||
|
{t('settings.instance.none')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<Button variant="ghost" onClick={() => void navigate('/connect')}>
|
||||||
|
{t('settings.instance.manage')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ember" onClick={handleSignOut}>
|
||||||
|
{t('settings.instance.signOut')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,243 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { Window, Card, Button, SegmentedControl, Callout } from '@olly/modern-sk';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
useGetDuplicatesQuery,
|
||||||
|
useGetBrokenFilesQuery,
|
||||||
|
useGetMissingMetadataQuery,
|
||||||
|
useRunCleanupMutation,
|
||||||
|
} from '../../api/endpoints/storage';
|
||||||
|
|
||||||
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
|
const PAGE_SIZE = 50;
|
||||||
|
type Tab = 'duplicates' | 'broken' | 'missing';
|
||||||
|
|
||||||
|
/** `/storage/maintenance` — A6 maintenance: duplicates, broken files, missing
|
||||||
|
* metadata, and orphan cleanup. */
|
||||||
export function StorageMaintenancePage() {
|
export function StorageMaintenancePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <Placeholder title={t('pages.storageMaintenance')} />;
|
const offline = useIsOffline();
|
||||||
|
const [tab, setTab] = useState<Tab>('duplicates');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
<Window title={t('pages.storageMaintenance')}>
|
||||||
|
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
|
||||||
|
{t('storage.maintenance.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{offline && (
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CleanupSection />
|
||||||
|
|
||||||
|
<div style={{ margin: '1.25rem 0' }}>
|
||||||
|
<SegmentedControl
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as Tab)}
|
||||||
|
items={[
|
||||||
|
{ value: 'duplicates', label: t('storage.maintenance.tabs.duplicates') },
|
||||||
|
{ value: 'broken', label: t('storage.maintenance.tabs.broken') },
|
||||||
|
{ value: 'missing', label: t('storage.maintenance.tabs.missing') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only the active tab is mounted, so only its query fires. */}
|
||||||
|
{tab === 'duplicates' && <DuplicatesTab />}
|
||||||
|
{tab === 'broken' && (
|
||||||
|
<TrackListTab useQuery={useGetBrokenFilesQuery} emptyKey="broken" />
|
||||||
|
)}
|
||||||
|
{tab === 'missing' && (
|
||||||
|
<TrackListTab useQuery={useGetMissingMetadataQuery} emptyKey="missing" />
|
||||||
|
)}
|
||||||
|
</Window>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orphan-file cleanup — admin only (enqueues a worker job). */
|
||||||
|
function CleanupSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const [runCleanup, { isLoading }] = useRunCleanupMutation();
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
setDone(false);
|
||||||
|
await runCleanup().unwrap();
|
||||||
|
setDone(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{t('storage.maintenance.cleanup.title')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('storage.maintenance.cleanup.description')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{done && (
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-accent)' }}>
|
||||||
|
{t('storage.maintenance.cleanup.enqueued')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ember" disabled={isLoading} onClick={() => void handle()}>
|
||||||
|
{t('storage.maintenance.cleanup.button')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duplicate groups (tracks sharing an acoustic fingerprint). */
|
||||||
|
function DuplicatesTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetDuplicatesQuery();
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSkeleton rows={5} height={56} />;
|
||||||
|
if (isError) return <ErrorState onRetry={refetch} />;
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState icon="✓" title={t('storage.maintenance.duplicates.empty')} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{data.map((group) => (
|
||||||
|
<Card key={group.fingerprint} style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.duplicates.groupTitle', {
|
||||||
|
count: group.tracks.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{group.tracks.map((track) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.id}
|
||||||
|
track={track}
|
||||||
|
showAlbum
|
||||||
|
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paginated track list backed by a maintenance query (broken / missing). */
|
||||||
|
function TrackListTab({
|
||||||
|
useQuery,
|
||||||
|
emptyKey,
|
||||||
|
}: {
|
||||||
|
useQuery:
|
||||||
|
| typeof useGetBrokenFilesQuery
|
||||||
|
| typeof useGetMissingMetadataQuery;
|
||||||
|
emptyKey: 'broken' | 'missing';
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSkeleton rows={6} height={56} />;
|
||||||
|
if (isError) return <ErrorState onRetry={refetch} />;
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="✓"
|
||||||
|
title={t(`storage.maintenance.${emptyKey}.empty`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = offset + 1;
|
||||||
|
const to = offset + data.items.length;
|
||||||
|
// Broken files map to the `failed` metadata bucket; missing → `pending`.
|
||||||
|
const batchStatus = emptyKey === 'broken' ? 'failed' : 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void navigate(`/metadata/batch?status=${batchStatus}`)}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.editBatch')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{data.items.map((track) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.id}
|
||||||
|
track={track}
|
||||||
|
showAlbum
|
||||||
|
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(offset > 0 || data.hasMore) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('storage.maintenance.pagination', {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
total: data.total,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.prev')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!data.hasMore}
|
||||||
|
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-23
@@ -10,6 +10,7 @@ import {
|
|||||||
import { nextTrack, prevTrack } from '../store/slices/queue';
|
import { nextTrack, prevTrack } from '../store/slices/queue';
|
||||||
import { play } from '../store/slices/player';
|
import { play } from '../store/slices/player';
|
||||||
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
||||||
|
import { useGetSettingsQuery } from '../api/endpoints/settings';
|
||||||
|
|
||||||
let audioElement: HTMLAudioElement | null = null;
|
let audioElement: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
@@ -26,54 +27,75 @@ export function useAudioPlayer() {
|
|||||||
const player = useAppSelector((s) => s.player);
|
const player = useAppSelector((s) => s.player);
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||||
const isSetup = useRef(false);
|
// Default stream quality (server-synced §A10) → the backend transcodes to it
|
||||||
// `ended` is registered once below; read the latest loop flag through a ref
|
// on demand; a cache miss transparently falls back to the original.
|
||||||
// so the listener doesn't need to be re-bound on every queue change.
|
const { data: settings } = useGetSettingsQuery();
|
||||||
|
const streamQuality = settings?.streamQuality;
|
||||||
|
// `ended` reads the latest loop flag through a ref so the listener doesn't
|
||||||
|
// need re-binding on every queue change.
|
||||||
const loopRef = useRef(queue.loop);
|
const loopRef = useRef(queue.loop);
|
||||||
loopRef.current = queue.loop;
|
loopRef.current = queue.loop;
|
||||||
|
// The track id the current <audio>.src points at — lets us tell a real track
|
||||||
|
// change (start at 0) from a same-track URL change (preserve position).
|
||||||
|
const loadedTrackRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSetup.current) return;
|
|
||||||
isSetup.current = true;
|
|
||||||
|
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
|
// Named handlers + cleanup so a remount (React StrictMode, or the hook
|
||||||
audio.addEventListener('timeupdate', () => {
|
// mounting elsewhere) can't leave duplicate listeners double-dispatching.
|
||||||
dispatch(setPosition(audio.currentTime));
|
const onTime = () => dispatch(setPosition(audio.currentTime));
|
||||||
});
|
const onDuration = () => dispatch(setDuration(audio.duration || 0));
|
||||||
audio.addEventListener('durationchange', () => {
|
const onEnded = () => {
|
||||||
dispatch(setDuration(audio.duration || 0));
|
|
||||||
});
|
|
||||||
audio.addEventListener('ended', () => {
|
|
||||||
if (loopRef.current) {
|
if (loopRef.current) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
void audio.play();
|
void audio.play();
|
||||||
} else {
|
} else {
|
||||||
dispatch(nextTrack());
|
dispatch(nextTrack());
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
audio.addEventListener('pause', () => {
|
const onPause = () => dispatch(pause());
|
||||||
dispatch(pause());
|
const onPlay = () => dispatch(resume());
|
||||||
});
|
|
||||||
audio.addEventListener('play', () => {
|
audio.addEventListener('timeupdate', onTime);
|
||||||
dispatch(resume());
|
audio.addEventListener('durationchange', onDuration);
|
||||||
});
|
audio.addEventListener('ended', onEnded);
|
||||||
|
audio.addEventListener('pause', onPause);
|
||||||
|
audio.addEventListener('play', onPlay);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', onTime);
|
||||||
|
audio.removeEventListener('durationchange', onDuration);
|
||||||
|
audio.removeEventListener('ended', onEnded);
|
||||||
|
audio.removeEventListener('pause', onPause);
|
||||||
|
audio.removeEventListener('play', onPlay);
|
||||||
|
};
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!player.currentTrackId || !accessToken) return;
|
if (!player.currentTrackId || !accessToken) return;
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
const url = getStreamUrl(player.currentTrackId, accessToken);
|
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
||||||
if (audio.src !== url) {
|
if (audio.src !== url) {
|
||||||
|
// Same track, new URL (quality changed, or the access token was refreshed
|
||||||
|
// mid-play) → keep the playback position instead of restarting from 0.
|
||||||
|
const sameTrack = loadedTrackRef.current === player.currentTrackId;
|
||||||
|
const resumeAt = sameTrack ? audio.currentTime : 0;
|
||||||
audio.src = url;
|
audio.src = url;
|
||||||
audio.load();
|
audio.load();
|
||||||
|
loadedTrackRef.current = player.currentTrackId;
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
const onMeta = () => {
|
||||||
|
audio.currentTime = resumeAt;
|
||||||
|
audio.removeEventListener('loadedmetadata', onMeta);
|
||||||
|
};
|
||||||
|
audio.addEventListener('loadedmetadata', onMeta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
void audio.play();
|
void audio.play();
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
}
|
}
|
||||||
}, [player.currentTrackId, player.isPlaying, accessToken]);
|
}, [player.currentTrackId, player.isPlaying, accessToken, streamQuality]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useStartRadioMutation } from '../api/endpoints/radio';
|
||||||
|
import { useAppDispatch } from './useAppDispatch';
|
||||||
|
import { startRadioQueue } from '../store/slices/queue';
|
||||||
|
import { pickToEntry } from '../lib/radio';
|
||||||
|
|
||||||
|
const DEFAULT_EXPLORATION = 0.25;
|
||||||
|
const INITIAL_COUNT = 20;
|
||||||
|
|
||||||
|
/** Start a radio (from a seed track or the caller's likes) and hand the picks to
|
||||||
|
* the play queue; RadioController then keeps it topped up. */
|
||||||
|
export function useStartRadio() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [startRadio, { isLoading }] = useStartRadioMutation();
|
||||||
|
|
||||||
|
const start = async (opts: { seedTrackId?: string; fromLikes?: boolean }) => {
|
||||||
|
const res = await startRadio({
|
||||||
|
...opts,
|
||||||
|
exploration: DEFAULT_EXPLORATION,
|
||||||
|
count: INITIAL_COUNT,
|
||||||
|
}).unwrap();
|
||||||
|
dispatch(
|
||||||
|
startRadioQueue({
|
||||||
|
entries: res.picks.map(pickToEntry),
|
||||||
|
radio: {
|
||||||
|
seedTrackId: opts.seedTrackId ?? null,
|
||||||
|
fromLikes: opts.fromLikes ?? false,
|
||||||
|
exploration: DEFAULT_EXPLORATION,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { start, isStarting: isLoading };
|
||||||
|
}
|
||||||
@@ -163,6 +163,16 @@ const en = {
|
|||||||
mute: 'Mute',
|
mute: 'Mute',
|
||||||
unmute: 'Unmute',
|
unmute: 'Unmute',
|
||||||
},
|
},
|
||||||
|
nowPlaying: {
|
||||||
|
expand: 'Now playing',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
artwork: 'Artwork',
|
||||||
|
lyrics: 'Lyrics',
|
||||||
|
},
|
||||||
|
lyrics: {
|
||||||
|
none: 'No lyrics found for this track.',
|
||||||
|
refetch: 'Look again',
|
||||||
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Play queue',
|
title: 'Play queue',
|
||||||
shuffle: 'Shuffle queue',
|
shuffle: 'Shuffle queue',
|
||||||
@@ -178,6 +188,13 @@ const en = {
|
|||||||
familiar: 'Familiar',
|
familiar: 'Familiar',
|
||||||
new: 'New',
|
new: 'New',
|
||||||
loadingMore: 'Loading more from radio…',
|
loadingMore: 'Loading more from radio…',
|
||||||
|
whyPlaying: 'Why this is playing',
|
||||||
|
reason: {
|
||||||
|
ml: 'Recommended',
|
||||||
|
similar: 'Similar',
|
||||||
|
from_likes: 'From your likes',
|
||||||
|
discover: 'Something new',
|
||||||
|
},
|
||||||
doubleClickPlay: 'Double-click to play',
|
doubleClickPlay: 'Double-click to play',
|
||||||
removeFromQueue: 'Remove from queue',
|
removeFromQueue: 'Remove from queue',
|
||||||
menu: {
|
menu: {
|
||||||
@@ -194,6 +211,7 @@ const en = {
|
|||||||
playNow: 'Play now',
|
playNow: 'Play now',
|
||||||
playNext: 'Play next',
|
playNext: 'Play next',
|
||||||
addToQueue: 'Add to queue',
|
addToQueue: 'Add to queue',
|
||||||
|
startRadio: 'Start radio',
|
||||||
info: 'Track info',
|
info: 'Track info',
|
||||||
addToPlaylist: 'Add to playlist…',
|
addToPlaylist: 'Add to playlist…',
|
||||||
removeFromPlaylist: 'Remove from playlist',
|
removeFromPlaylist: 'Remove from playlist',
|
||||||
@@ -286,6 +304,36 @@ const en = {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
subtitle: 'Everything this instance has tucked away',
|
||||||
|
maintenance: {
|
||||||
|
subtitle:
|
||||||
|
'Find duplicates, tracks that failed enrichment, and files still awaiting metadata — then reconcile orphaned rows.',
|
||||||
|
tabs: {
|
||||||
|
duplicates: 'Duplicates',
|
||||||
|
broken: 'Failed',
|
||||||
|
missing: 'No metadata',
|
||||||
|
},
|
||||||
|
cleanup: {
|
||||||
|
title: 'Clean up orphaned files',
|
||||||
|
description:
|
||||||
|
'Scan the catalogue and remove rows whose backing file has vanished. Runs in the background.',
|
||||||
|
button: 'Run cleanup',
|
||||||
|
enqueued: 'Cleanup queued',
|
||||||
|
},
|
||||||
|
duplicates: {
|
||||||
|
empty: 'No duplicates found.',
|
||||||
|
groupTitle: '{{count}} tracks share a fingerprint',
|
||||||
|
},
|
||||||
|
broken: {
|
||||||
|
empty: 'No failed tracks.',
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
empty: 'Every track has metadata.',
|
||||||
|
},
|
||||||
|
pagination: '{{from}}–{{to}} of {{total}}',
|
||||||
|
prev: 'Previous',
|
||||||
|
next: 'Next',
|
||||||
|
editBatch: 'Edit in batch',
|
||||||
|
},
|
||||||
device: 'On this device',
|
device: 'On this device',
|
||||||
server: 'On the server',
|
server: 'On the server',
|
||||||
audioCache: 'Cached audio',
|
audioCache: 'Cached audio',
|
||||||
@@ -357,6 +405,47 @@ const en = {
|
|||||||
scrobbling: 'Scrobbling',
|
scrobbling: 'Scrobbling',
|
||||||
instance: 'Instance',
|
instance: 'Instance',
|
||||||
},
|
},
|
||||||
|
common: {
|
||||||
|
save: 'Save',
|
||||||
|
saving: 'Saving…',
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
current: 'Current instance',
|
||||||
|
none: 'No instance selected.',
|
||||||
|
manage: 'Manage instances',
|
||||||
|
signOut: 'Sign out',
|
||||||
|
},
|
||||||
|
playback: {
|
||||||
|
streamQuality: 'Streaming quality',
|
||||||
|
streamQualityHint:
|
||||||
|
'Audio quality when streaming from the server. "Original" streams the file as-is.',
|
||||||
|
quality: {
|
||||||
|
original: 'Original',
|
||||||
|
high: 'High',
|
||||||
|
medium: 'Medium',
|
||||||
|
low: 'Low',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobbling: {
|
||||||
|
description: 'Send your listens to Last.fm or ListenBrainz.',
|
||||||
|
configured: 'Linked',
|
||||||
|
notConfigured: 'Not linked',
|
||||||
|
enabled: 'Scrobbling',
|
||||||
|
on: 'On',
|
||||||
|
off: 'Off',
|
||||||
|
provider: 'Service',
|
||||||
|
lastfm: 'Last.fm',
|
||||||
|
listenbrainz: 'ListenBrainz',
|
||||||
|
username: 'Username',
|
||||||
|
usernamePlaceholder: 'your account name',
|
||||||
|
sessionKey: 'Session key / token',
|
||||||
|
sessionKeyPlaceholder: 'paste your token',
|
||||||
|
sessionKeyKeep: 'leave blank to keep the current token',
|
||||||
|
sessionKeyHint:
|
||||||
|
'Stored encrypted on the server and never shown again.',
|
||||||
|
saved: 'Scrobbling settings saved.',
|
||||||
|
error: 'Could not save. Please try again.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
userDetail: 'User',
|
userDetail: 'User',
|
||||||
@@ -365,6 +454,77 @@ const en = {
|
|||||||
sources: 'Sources',
|
sources: 'Sources',
|
||||||
instance: 'Instance',
|
instance: 'Instance',
|
||||||
},
|
},
|
||||||
|
users: {
|
||||||
|
add: 'Add user',
|
||||||
|
empty: 'No users yet.',
|
||||||
|
you: '(you)',
|
||||||
|
added: 'Added {{date}}',
|
||||||
|
role: {
|
||||||
|
admin: 'Admin',
|
||||||
|
user: 'User',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
blocked: 'Blocked',
|
||||||
|
},
|
||||||
|
newDialog: {
|
||||||
|
title: 'Add user',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
create: 'Create',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
error: 'Could not create the user — the name may be taken.',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
options: 'Options',
|
||||||
|
makeAdmin: 'Make admin',
|
||||||
|
makeUser: 'Revoke admin',
|
||||||
|
resetPassword: 'Reset password',
|
||||||
|
activate: 'Activate',
|
||||||
|
deactivate: 'Deactivate',
|
||||||
|
},
|
||||||
|
resetDialog: {
|
||||||
|
title: 'Reset password — {{name}}',
|
||||||
|
newPassword: 'New password',
|
||||||
|
submit: 'Set password',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
done: 'Password updated.',
|
||||||
|
},
|
||||||
|
deactivateDialog: {
|
||||||
|
title: 'Deactivate user?',
|
||||||
|
description:
|
||||||
|
'{{name}} will be signed out and blocked from signing in. You can reactivate them later.',
|
||||||
|
confirm: 'Deactivate',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
status: {
|
||||||
|
ok: 'Healthy',
|
||||||
|
down: 'Down',
|
||||||
|
skipped: 'Not configured',
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
title: 'Services',
|
||||||
|
database: 'Database',
|
||||||
|
redis: 'Redis',
|
||||||
|
ml: 'ML service',
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
title: 'Sources',
|
||||||
|
envNote: 'Sources are configured via environment variables.',
|
||||||
|
empty: 'No sources configured.',
|
||||||
|
available: 'Available',
|
||||||
|
unavailable: 'Unavailable',
|
||||||
|
},
|
||||||
|
reindex: {
|
||||||
|
title: 'Reindex library',
|
||||||
|
description:
|
||||||
|
'Re-scan every indexable source. Runs in the background and is idempotent.',
|
||||||
|
button: 'Reindex',
|
||||||
|
enqueued: 'Reindex queued ({{count}} sources).',
|
||||||
|
noSource: 'No indexable source is configured.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
notFound: {
|
notFound: {
|
||||||
title: 'Page not found',
|
title: 'Page not found',
|
||||||
@@ -453,10 +613,35 @@ const en = {
|
|||||||
manual: 'Edited manually — not auto-updated',
|
manual: 'Edited manually — not auto-updated',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadataBatch: {
|
||||||
|
selectedCount: '{{selected}} of {{total}} selected',
|
||||||
|
status: {
|
||||||
|
pending: 'No metadata',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
result: '{{ok}} updated, {{fail}} failed',
|
||||||
|
reEnrich: {
|
||||||
|
title: 'Re-run auto-enrichment',
|
||||||
|
hint: 'Queue AcoustID/MusicBrainz identification for the selected tracks.',
|
||||||
|
button: 'Re-enrich selected',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
title: 'Apply common fields',
|
||||||
|
hint: 'Filled fields are written to every selected track; blank fields are left unchanged.',
|
||||||
|
apply: 'Apply to selected',
|
||||||
|
},
|
||||||
|
empty: 'Nothing in this bucket.',
|
||||||
|
},
|
||||||
metadataEditor: {
|
metadataEditor: {
|
||||||
error: 'Failed to load track',
|
error: 'Failed to load track',
|
||||||
saved: 'Metadata saved.',
|
saved: 'Metadata saved.',
|
||||||
saveError: 'Failed to save metadata.',
|
saveError: 'Failed to save metadata.',
|
||||||
|
optimize: {
|
||||||
|
title: 'Optimize format',
|
||||||
|
hint: 'Transcode to a compact Opus + HLS rendition in the background.',
|
||||||
|
button: 'Optimize',
|
||||||
|
enqueued: 'Optimization queued — it runs in the background.',
|
||||||
|
},
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
|||||||
@@ -164,6 +164,16 @@ const ru: Translations = {
|
|||||||
mute: 'Выключить звук',
|
mute: 'Выключить звук',
|
||||||
unmute: 'Включить звук',
|
unmute: 'Включить звук',
|
||||||
},
|
},
|
||||||
|
nowPlaying: {
|
||||||
|
expand: 'Сейчас играет',
|
||||||
|
collapse: 'Свернуть',
|
||||||
|
artwork: 'Обложка',
|
||||||
|
lyrics: 'Текст',
|
||||||
|
},
|
||||||
|
lyrics: {
|
||||||
|
none: 'Текст для этого трека не найден.',
|
||||||
|
refetch: 'Искать снова',
|
||||||
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Очередь воспроизведения',
|
title: 'Очередь воспроизведения',
|
||||||
shuffle: 'Перемешать очередь',
|
shuffle: 'Перемешать очередь',
|
||||||
@@ -179,6 +189,13 @@ const ru: Translations = {
|
|||||||
familiar: 'Знакомое',
|
familiar: 'Знакомое',
|
||||||
new: 'Новое',
|
new: 'Новое',
|
||||||
loadingMore: 'Загрузка радио…',
|
loadingMore: 'Загрузка радио…',
|
||||||
|
whyPlaying: 'Почему это играет',
|
||||||
|
reason: {
|
||||||
|
ml: 'Рекомендация',
|
||||||
|
similar: 'Похожее',
|
||||||
|
from_likes: 'Из ваших лайков',
|
||||||
|
discover: 'Что-то новое',
|
||||||
|
},
|
||||||
doubleClickPlay: 'Двойной клик для воспроизведения',
|
doubleClickPlay: 'Двойной клик для воспроизведения',
|
||||||
removeFromQueue: 'Убрать из очереди',
|
removeFromQueue: 'Убрать из очереди',
|
||||||
menu: {
|
menu: {
|
||||||
@@ -195,6 +212,7 @@ const ru: Translations = {
|
|||||||
playNow: 'Играть сейчас',
|
playNow: 'Играть сейчас',
|
||||||
playNext: 'Следующим',
|
playNext: 'Следующим',
|
||||||
addToQueue: 'Добавить в очередь',
|
addToQueue: 'Добавить в очередь',
|
||||||
|
startRadio: 'Запустить радио',
|
||||||
info: 'Информация о треке',
|
info: 'Информация о треке',
|
||||||
addToPlaylist: 'Добавить в плейлист…',
|
addToPlaylist: 'Добавить в плейлист…',
|
||||||
removeFromPlaylist: 'Убрать из плейлиста',
|
removeFromPlaylist: 'Убрать из плейлиста',
|
||||||
@@ -287,6 +305,36 @@ const ru: Translations = {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Всё, что хранит этот инстанс',
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
maintenance: {
|
||||||
|
subtitle:
|
||||||
|
'Найдите дубликаты, треки с ошибкой обогащения и файлы без метаданных — и уберите осиротевшие записи.',
|
||||||
|
tabs: {
|
||||||
|
duplicates: 'Дубликаты',
|
||||||
|
broken: 'С ошибкой',
|
||||||
|
missing: 'Без метаданных',
|
||||||
|
},
|
||||||
|
cleanup: {
|
||||||
|
title: 'Очистить осиротевшие файлы',
|
||||||
|
description:
|
||||||
|
'Сверить каталог и удалить записи, у которых пропал файл на диске. Выполняется в фоне.',
|
||||||
|
button: 'Запустить очистку',
|
||||||
|
enqueued: 'Очистка поставлена в очередь',
|
||||||
|
},
|
||||||
|
duplicates: {
|
||||||
|
empty: 'Дубликатов не найдено.',
|
||||||
|
groupTitle: '{{count}} треков с одним отпечатком',
|
||||||
|
},
|
||||||
|
broken: {
|
||||||
|
empty: 'Треков с ошибкой нет.',
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
empty: 'У всех треков есть метаданные.',
|
||||||
|
},
|
||||||
|
pagination: '{{from}}–{{to}} из {{total}}',
|
||||||
|
prev: 'Назад',
|
||||||
|
next: 'Вперёд',
|
||||||
|
editBatch: 'Редактировать массово',
|
||||||
|
},
|
||||||
device: 'На этом устройстве',
|
device: 'На этом устройстве',
|
||||||
server: 'На сервере',
|
server: 'На сервере',
|
||||||
audioCache: 'Кэш аудио',
|
audioCache: 'Кэш аудио',
|
||||||
@@ -358,6 +406,47 @@ const ru: Translations = {
|
|||||||
scrobbling: 'Скробблинг',
|
scrobbling: 'Скробблинг',
|
||||||
instance: 'Сервер',
|
instance: 'Сервер',
|
||||||
},
|
},
|
||||||
|
common: {
|
||||||
|
save: 'Сохранить',
|
||||||
|
saving: 'Сохранение…',
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
current: 'Текущий инстанс',
|
||||||
|
none: 'Инстанс не выбран.',
|
||||||
|
manage: 'Управление инстансами',
|
||||||
|
signOut: 'Выйти',
|
||||||
|
},
|
||||||
|
playback: {
|
||||||
|
streamQuality: 'Качество потока',
|
||||||
|
streamQualityHint:
|
||||||
|
'Качество звука при стриминге с сервера. «Оригинал» отдаёт файл как есть.',
|
||||||
|
quality: {
|
||||||
|
original: 'Оригинал',
|
||||||
|
high: 'Высокое',
|
||||||
|
medium: 'Среднее',
|
||||||
|
low: 'Низкое',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobbling: {
|
||||||
|
description: 'Отправляйте свои прослушивания в Last.fm или ListenBrainz.',
|
||||||
|
configured: 'Подключено',
|
||||||
|
notConfigured: 'Не подключено',
|
||||||
|
enabled: 'Скробблинг',
|
||||||
|
on: 'Вкл',
|
||||||
|
off: 'Выкл',
|
||||||
|
provider: 'Сервис',
|
||||||
|
lastfm: 'Last.fm',
|
||||||
|
listenbrainz: 'ListenBrainz',
|
||||||
|
username: 'Имя пользователя',
|
||||||
|
usernamePlaceholder: 'ваш логин',
|
||||||
|
sessionKey: 'Ключ сессии / токен',
|
||||||
|
sessionKeyPlaceholder: 'вставьте токен',
|
||||||
|
sessionKeyKeep: 'оставьте пустым, чтобы сохранить текущий токен',
|
||||||
|
sessionKeyHint:
|
||||||
|
'Хранится на сервере в зашифрованном виде и больше не показывается.',
|
||||||
|
saved: 'Настройки скробблинга сохранены.',
|
||||||
|
error: 'Не удалось сохранить. Попробуйте ещё раз.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
userDetail: 'Пользователь',
|
userDetail: 'Пользователь',
|
||||||
@@ -366,6 +455,77 @@ const ru: Translations = {
|
|||||||
sources: 'Источники',
|
sources: 'Источники',
|
||||||
instance: 'Сервер',
|
instance: 'Сервер',
|
||||||
},
|
},
|
||||||
|
users: {
|
||||||
|
add: 'Добавить',
|
||||||
|
empty: 'Пользователей пока нет.',
|
||||||
|
you: '(вы)',
|
||||||
|
added: 'Добавлен {{date}}',
|
||||||
|
role: {
|
||||||
|
admin: 'Админ',
|
||||||
|
user: 'Пользователь',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
blocked: 'Заблокирован',
|
||||||
|
},
|
||||||
|
newDialog: {
|
||||||
|
title: 'Новый пользователь',
|
||||||
|
username: 'Логин',
|
||||||
|
password: 'Пароль',
|
||||||
|
create: 'Создать',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
error: 'Не удалось создать пользователя — возможно, логин занят.',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
options: 'Действия',
|
||||||
|
makeAdmin: 'Сделать админом',
|
||||||
|
makeUser: 'Снять админа',
|
||||||
|
resetPassword: 'Сбросить пароль',
|
||||||
|
activate: 'Разблокировать',
|
||||||
|
deactivate: 'Заблокировать',
|
||||||
|
},
|
||||||
|
resetDialog: {
|
||||||
|
title: 'Сброс пароля — {{name}}',
|
||||||
|
newPassword: 'Новый пароль',
|
||||||
|
submit: 'Задать пароль',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
done: 'Пароль обновлён.',
|
||||||
|
},
|
||||||
|
deactivateDialog: {
|
||||||
|
title: 'Заблокировать пользователя?',
|
||||||
|
description:
|
||||||
|
'{{name}} будет разлогинен и не сможет войти. Позже можно разблокировать.',
|
||||||
|
confirm: 'Заблокировать',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
status: {
|
||||||
|
ok: 'Работает',
|
||||||
|
down: 'Недоступен',
|
||||||
|
skipped: 'Не настроен',
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
title: 'Сервисы',
|
||||||
|
database: 'База данных',
|
||||||
|
redis: 'Redis',
|
||||||
|
ml: 'ML-сервис',
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
title: 'Источники',
|
||||||
|
envNote: 'Источники настраиваются через переменные окружения.',
|
||||||
|
empty: 'Источники не настроены.',
|
||||||
|
available: 'Доступен',
|
||||||
|
unavailable: 'Недоступен',
|
||||||
|
},
|
||||||
|
reindex: {
|
||||||
|
title: 'Переиндексация библиотеки',
|
||||||
|
description:
|
||||||
|
'Пересканировать все индексируемые источники. Выполняется в фоне, идемпотентно.',
|
||||||
|
button: 'Переиндексировать',
|
||||||
|
enqueued: 'Переиндексация запущена ({{count}} источников).',
|
||||||
|
noSource: 'Индексируемые источники не настроены.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
notFound: {
|
notFound: {
|
||||||
title: 'Страница не найдена',
|
title: 'Страница не найдена',
|
||||||
@@ -455,7 +615,32 @@ const ru: Translations = {
|
|||||||
manual: 'Изменено вручную — не обновляется автоматически',
|
manual: 'Изменено вручную — не обновляется автоматически',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadataBatch: {
|
||||||
|
selectedCount: 'Выбрано {{selected}} из {{total}}',
|
||||||
|
status: {
|
||||||
|
pending: 'Без метаданных',
|
||||||
|
failed: 'С ошибкой',
|
||||||
|
},
|
||||||
|
result: 'Обновлено {{ok}}, ошибок {{fail}}',
|
||||||
|
reEnrich: {
|
||||||
|
title: 'Повторное авто-обогащение',
|
||||||
|
hint: 'Поставить в очередь идентификацию AcoustID/MusicBrainz для выбранных треков.',
|
||||||
|
button: 'Переобогатить выбранные',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
title: 'Применить общие поля',
|
||||||
|
hint: 'Заполненные поля запишутся всем выбранным трекам; пустые останутся без изменений.',
|
||||||
|
apply: 'Применить к выбранным',
|
||||||
|
},
|
||||||
|
empty: 'В этой категории пусто.',
|
||||||
|
},
|
||||||
metadataEditor: {
|
metadataEditor: {
|
||||||
|
optimize: {
|
||||||
|
title: 'Оптимизировать формат',
|
||||||
|
hint: 'Перекодировать в компактный Opus + HLS в фоне.',
|
||||||
|
button: 'Оптимизировать',
|
||||||
|
enqueued: 'Оптимизация поставлена в очередь — выполнится в фоне.',
|
||||||
|
},
|
||||||
error: 'Не удалось загрузить трек',
|
error: 'Не удалось загрузить трек',
|
||||||
saved: 'Метаданные сохранены.',
|
saved: 'Метаданные сохранены.',
|
||||||
saveError: 'Не удалось сохранить метаданные.',
|
saveError: 'Не удалось сохранить метаданные.',
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ 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';
|
||||||
|
import './api/endpoints/settings';
|
||||||
import './api/endpoints/upload';
|
import './api/endpoints/upload';
|
||||||
|
import './api/endpoints/lyrics';
|
||||||
|
import './api/endpoints/radio';
|
||||||
|
|
||||||
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||||
// browser/origin doesn't support it).
|
// browser/origin doesn't support it).
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** One parsed LRC line: a timestamp (seconds) and its text. */
|
||||||
|
export interface LrcLine {
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an LRC string into time-sorted lines. A single source line may carry
|
||||||
|
* several timestamps (repeated lyric) — each becomes its own entry. Metadata
|
||||||
|
* tags (`[ar:…]`, `[ti:…]`) and untimed lines are skipped; empty-text lines are
|
||||||
|
* kept so the active-line highlight advances through instrumental gaps.
|
||||||
|
*/
|
||||||
|
export function parseLrc(lrc: string): LrcLine[] {
|
||||||
|
const lines: LrcLine[] = [];
|
||||||
|
for (const raw of lrc.split(/\r?\n/)) {
|
||||||
|
const stamps = [...raw.matchAll(TAG)];
|
||||||
|
if (stamps.length === 0) continue;
|
||||||
|
const text = raw.replace(/\[[^\]]*\]/g, '').trim();
|
||||||
|
for (const m of stamps) {
|
||||||
|
const min = Number(m[1]);
|
||||||
|
const sec = Number(m[2]);
|
||||||
|
const frac = m[3] ? Number(m[3].padEnd(3, '0')) / 1000 : 0;
|
||||||
|
lines.push({ time: min * 60 + sec + frac, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the active line for a playback position (seconds) — the last line
|
||||||
|
* whose timestamp has passed. Returns -1 before the first line. Assumes `lines`
|
||||||
|
* is time-sorted (as `parseLrc` returns).
|
||||||
|
*/
|
||||||
|
export function activeLineIndex(lines: LrcLine[], position: number): number {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = lines.length - 1;
|
||||||
|
let result = -1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (lines[mid].time <= position) {
|
||||||
|
result = mid;
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { RadioPick } from '../api/types';
|
||||||
|
import type { QueueEntry } from '../store/slices/queue';
|
||||||
|
|
||||||
|
/** A radio/similar pick → a play-queue entry (carrying its reason code). */
|
||||||
|
export function pickToEntry(pick: RadioPick): QueueEntry {
|
||||||
|
return {
|
||||||
|
trackId: pick.track.id,
|
||||||
|
title: pick.track.title,
|
||||||
|
artistName: pick.track.artistName,
|
||||||
|
albumTitle: pick.track.albumTitle,
|
||||||
|
durationMs: pick.track.durationMs,
|
||||||
|
albumArtUrl: pick.track.albumArtUrl,
|
||||||
|
reason: pick.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export interface PlayerState {
|
|||||||
volume: number;
|
volume: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
isQueueOpen: boolean;
|
isQueueOpen: boolean;
|
||||||
|
isNowPlayingOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const playerInitialState: PlayerState = {
|
export const playerInitialState: PlayerState = {
|
||||||
@@ -18,6 +19,7 @@ export const playerInitialState: PlayerState = {
|
|||||||
volume: 0.78,
|
volume: 0.78,
|
||||||
muted: false,
|
muted: false,
|
||||||
isQueueOpen: false,
|
isQueueOpen: false,
|
||||||
|
isNowPlayingOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerSlice = createSlice({
|
export const playerSlice = createSlice({
|
||||||
@@ -55,6 +57,12 @@ export const playerSlice = createSlice({
|
|||||||
toggleQueue(state) {
|
toggleQueue(state) {
|
||||||
state.isQueueOpen = !state.isQueueOpen;
|
state.isQueueOpen = !state.isQueueOpen;
|
||||||
},
|
},
|
||||||
|
toggleNowPlaying(state) {
|
||||||
|
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
||||||
|
},
|
||||||
|
setNowPlayingOpen(state, action: PayloadAction<boolean>) {
|
||||||
|
state.isNowPlayingOpen = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,5 +76,7 @@ export const {
|
|||||||
setVolume,
|
setVolume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
|
toggleNowPlaying,
|
||||||
|
setNowPlayingOpen,
|
||||||
} = playerSlice.actions;
|
} = playerSlice.actions;
|
||||||
export default playerSlice.reducer;
|
export default playerSlice.reducer;
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ export interface QueueEntry {
|
|||||||
albumTitle: string;
|
albumTitle: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
albumArtUrl?: string;
|
albumArtUrl?: string;
|
||||||
|
/** Radio reason code (`ml`/`similar`/`from_likes`/`discover`) — §6.5. */
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active-radio context: what seeds the infinite feed + the exploration bias. */
|
||||||
|
export interface RadioContext {
|
||||||
|
seedTrackId: string | null;
|
||||||
|
fromLikes: boolean;
|
||||||
|
exploration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueState {
|
export interface QueueState {
|
||||||
@@ -25,8 +34,16 @@ export interface QueueState {
|
|||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
|
/** Non-null while a radio drives the queue (enables infinite loading). */
|
||||||
|
radio: RadioContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An infinite radio keeps appending, so cap the backlog: once the queue grows
|
||||||
|
// past QUEUE_CAP, drop the oldest *already-played* entries (never the current
|
||||||
|
// track or the look-ahead), keeping at least KEEP_BEHIND for prev-navigation.
|
||||||
|
const QUEUE_CAP = 300;
|
||||||
|
const KEEP_BEHIND = 50;
|
||||||
|
|
||||||
export const queueInitialState: QueueState = {
|
export const queueInitialState: QueueState = {
|
||||||
entries: [],
|
entries: [],
|
||||||
currentIndex: -1,
|
currentIndex: -1,
|
||||||
@@ -35,6 +52,7 @@ export const queueInitialState: QueueState = {
|
|||||||
sourceName: null,
|
sourceName: null,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
loop: false,
|
loop: false,
|
||||||
|
radio: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueSlice = createSlice({
|
export const queueSlice = createSlice({
|
||||||
@@ -56,6 +74,38 @@ export const queueSlice = createSlice({
|
|||||||
state.source = action.payload.source;
|
state.source = action.payload.source;
|
||||||
state.sourceId = action.payload.sourceId ?? null;
|
state.sourceId = action.payload.sourceId ?? null;
|
||||||
state.sourceName = action.payload.sourceName ?? null;
|
state.sourceName = action.payload.sourceName ?? null;
|
||||||
|
state.radio = null; // a manual/album/… queue ends any active radio
|
||||||
|
},
|
||||||
|
startRadioQueue(
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ entries: QueueEntry[]; radio: RadioContext }>,
|
||||||
|
) {
|
||||||
|
state.entries = action.payload.entries;
|
||||||
|
state.currentIndex = 0;
|
||||||
|
state.source = 'radio';
|
||||||
|
state.sourceId = null;
|
||||||
|
state.sourceName = null;
|
||||||
|
state.radio = action.payload.radio;
|
||||||
|
},
|
||||||
|
appendToQueue(state, action: PayloadAction<QueueEntry[]>) {
|
||||||
|
state.entries.push(...action.payload);
|
||||||
|
if (state.entries.length > QUEUE_CAP) {
|
||||||
|
// Only trim entries safely behind the current one.
|
||||||
|
const drop = Math.min(
|
||||||
|
state.entries.length - QUEUE_CAP,
|
||||||
|
Math.max(0, state.currentIndex - KEEP_BEHIND),
|
||||||
|
);
|
||||||
|
if (drop > 0) {
|
||||||
|
state.entries.splice(0, drop);
|
||||||
|
state.currentIndex -= drop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setExploration(state, action: PayloadAction<number>) {
|
||||||
|
if (state.radio) state.radio.exploration = action.payload;
|
||||||
|
},
|
||||||
|
stopRadio(state) {
|
||||||
|
state.radio = null;
|
||||||
},
|
},
|
||||||
addToQueue(state, action: PayloadAction<QueueEntry>) {
|
addToQueue(state, action: PayloadAction<QueueEntry>) {
|
||||||
state.entries.push(action.payload);
|
state.entries.push(action.payload);
|
||||||
@@ -102,6 +152,7 @@ export const queueSlice = createSlice({
|
|||||||
clearQueue(state) {
|
clearQueue(state) {
|
||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.currentIndex = -1;
|
state.currentIndex = -1;
|
||||||
|
state.radio = null;
|
||||||
},
|
},
|
||||||
toggleShuffle(state) {
|
toggleShuffle(state) {
|
||||||
state.shuffle = !state.shuffle;
|
state.shuffle = !state.shuffle;
|
||||||
@@ -114,6 +165,10 @@ export const queueSlice = createSlice({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
setQueue,
|
setQueue,
|
||||||
|
startRadioQueue,
|
||||||
|
appendToQueue,
|
||||||
|
setExploration,
|
||||||
|
stopRadio,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
addNextInQueue,
|
addNextInQueue,
|
||||||
playNow,
|
playNow,
|
||||||
|
|||||||
Reference in New Issue
Block a user