Compare commits
10 Commits
57044dabba
...
sloppyslop
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f7fd25ff | |||
| 5b31e77b1e | |||
| 7f116f0657 | |||
| ac6b76bf98 | |||
| e45ef785cf | |||
| 9d88d70be0 | |||
| 6efbf4b68b | |||
| 87fac7a5a1 | |||
| 3eba3701d0 | |||
| 28d9e59e34 |
@@ -1,6 +1,11 @@
|
||||
import { api } from '../index';
|
||||
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` /
|
||||
@@ -14,6 +19,11 @@ export const adminApi = api.injectEndpoints({
|
||||
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||
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<
|
||||
User,
|
||||
{ username: string; password: string; role: 'admin' | 'user' }
|
||||
@@ -39,11 +49,34 @@ export const adminApi = api.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
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>({
|
||||
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,
|
||||
@@ -51,7 +84,12 @@ export const adminApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetUsersQuery,
|
||||
useGetUserQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useResetPasswordMutation,
|
||||
useGetServicesQuery,
|
||||
useGetSourcesQuery,
|
||||
useReindexMutation,
|
||||
} = adminApi;
|
||||
|
||||
@@ -201,6 +201,18 @@ export const libraryApi = api.injectEndpoints({
|
||||
}),
|
||||
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,
|
||||
});
|
||||
@@ -219,4 +231,5 @@ export const {
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
useApplyMetadataMutation,
|
||||
useEnrichTrackMutation,
|
||||
useOptimizeTrackMutation,
|
||||
} = 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;
|
||||
@@ -1,10 +1,32 @@
|
||||
import { api } from '../index';
|
||||
import { toStorageStats, type RawStorageStats } from '../mappers';
|
||||
import type { StorageStats } from '../types';
|
||||
import {
|
||||
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
|
||||
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
||||
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
||||
// routes back the Storage Maintenance screen: `/duplicates`, `/broken`,
|
||||
// `/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({
|
||||
endpoints: (build) => ({
|
||||
@@ -13,16 +35,32 @@ export const storageApi = api.injectEndpoints({
|
||||
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||
providesTags: ['Storage'],
|
||||
}),
|
||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
||||
query: () => ({ url: '/storage/scan', method: 'POST' }),
|
||||
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
|
||||
getDuplicates: build.query<DuplicateGroup[], void>({
|
||||
query: () => '/storage/duplicates',
|
||||
transformResponse: (raw: RawDuplicateGroup[]) =>
|
||||
raw.map((g) => ({
|
||||
fingerprint: g.fingerprint,
|
||||
tracks: g.tracks.map(toTrack),
|
||||
})),
|
||||
providesTags: ['Storage', 'Track'],
|
||||
}),
|
||||
deleteTrackFile: build.mutation<void, string>({
|
||||
query: (trackId) => ({
|
||||
url: `/storage/tracks/${trackId}`,
|
||||
method: 'DELETE',
|
||||
getBrokenFiles: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||
query: (p) => ({ url: '/storage/broken', params: p ?? {} }),
|
||||
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||
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,
|
||||
@@ -30,6 +68,8 @@ export const storageApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetStorageStatsQuery,
|
||||
useScanStorageMutation,
|
||||
useDeleteTrackFileMutation,
|
||||
useGetDuplicatesQuery,
|
||||
useGetBrokenFilesQuery,
|
||||
useGetMissingMetadataQuery,
|
||||
useRunCleanupMutation,
|
||||
} = storageApi;
|
||||
|
||||
@@ -5,9 +5,17 @@ import { getApiBaseUrl } from '../../config/runtime-config';
|
||||
* param because `<audio>` can't send an `Authorization` header; the backend
|
||||
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
||||
*/
|
||||
export function getStreamUrl(trackId: string, token: string): string {
|
||||
export function getStreamUrl(
|
||||
trackId: string,
|
||||
token: string,
|
||||
quality?: string,
|
||||
): string {
|
||||
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 {
|
||||
|
||||
@@ -128,6 +128,7 @@ export const toUser = (r: RawUser): User => ({
|
||||
username: r.username,
|
||||
// MVP role model: superuser → admin, everyone else → user.
|
||||
role: r.is_superuser ? 'admin' : 'user',
|
||||
isActive: r.is_active,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
|
||||
|
||||
@@ -140,6 +140,16 @@ export interface UploadResponse {
|
||||
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 {
|
||||
fileFormat: string;
|
||||
trackCount: number;
|
||||
@@ -176,11 +186,43 @@ export interface StorageStats {
|
||||
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 {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'user';
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Outlet } from 'react-router';
|
||||
import { Suspense } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||
import { NowPlaying } from '../player/NowPlaying';
|
||||
import { QueuePanel } from '../player/QueuePanel';
|
||||
import { RadioController } from '../player/RadioController';
|
||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||
@@ -37,6 +39,8 @@ export function AppShell() {
|
||||
</div>
|
||||
<AddToPlaylistDialog />
|
||||
<PersistentPlayer />
|
||||
<NowPlaying />
|
||||
<RadioController />
|
||||
</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,
|
||||
setVolume,
|
||||
toggleQueue,
|
||||
toggleNowPlaying,
|
||||
} from '../../store/slices/player';
|
||||
import { openTrackInfo } from '../../store/slices/ui';
|
||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||
@@ -145,6 +146,14 @@ export function PersistentPlayer() {
|
||||
>
|
||||
<Icon name="queue" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(toggleNowPlaying())}
|
||||
title={t('nowPlaying.expand')}
|
||||
>
|
||||
<Icon name="vinyl-record" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
clearQueue,
|
||||
toggleShuffle,
|
||||
toggleLoop,
|
||||
setExploration,
|
||||
type QueueEntry,
|
||||
} from '../../store/slices/queue';
|
||||
import { toggleQueue } from '../../store/slices/player';
|
||||
@@ -148,7 +149,8 @@ export function QueuePanel() {
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[42]}
|
||||
value={[Math.round((queue.radio?.exploration ?? 0.25) * 100)]}
|
||||
onValueChange={([v]) => dispatch(setExploration(v / 100))}
|
||||
aria-label="Exploration"
|
||||
/>
|
||||
<span className="lab">{t('queue.new')}</span>
|
||||
@@ -277,6 +279,14 @@ function QueueRow({
|
||||
className="r"
|
||||
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>
|
||||
<Menu>
|
||||
<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 { play } from '../../store/slices/player';
|
||||
import { openTrackInfo } from '../../store/slices/ui';
|
||||
import { useStartRadio } from '../../hooks/useStartRadio';
|
||||
import type { Track } from '../../api/types';
|
||||
|
||||
interface Props {
|
||||
@@ -32,6 +33,7 @@ export function TrackContextMenu({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { start: startRadio } = useStartRadio();
|
||||
|
||||
const entry = {
|
||||
trackId: track.id,
|
||||
@@ -75,6 +77,9 @@ export function TrackContextMenu({
|
||||
>
|
||||
{t('track.menu.addToQueue')}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => void startRadio({ seedTrackId: track.id })}>
|
||||
{t('track.menu.startRadio')}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,16 +46,430 @@ function StubPanel({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users` — user list (add/remove). Scaffold. */
|
||||
export function AdminUsers() {
|
||||
/** Role + active-status badges shared by the list and the detail view. */
|
||||
function UserBadges({ user }: { user: User }) {
|
||||
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() {
|
||||
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. */
|
||||
@@ -30,8 +478,174 @@ export function AdminSources() {
|
||||
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() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Card,
|
||||
IconButton,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Spinner,
|
||||
TextField,
|
||||
} from '@olly/modern-sk';
|
||||
@@ -16,11 +17,17 @@ import {
|
||||
useEnrichTrackMutation,
|
||||
useGetTrackQuery,
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
useOptimizeTrackMutation,
|
||||
} 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 { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
|
||||
interface Props {
|
||||
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
||||
@@ -58,15 +65,31 @@ function fieldStyle(): React.CSSProperties {
|
||||
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
|
||||
* 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) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (batch) {
|
||||
return <Placeholder title={t('pages.metadataBatch')} />;
|
||||
return <BatchEditor />;
|
||||
}
|
||||
|
||||
return <SingleTrackEditor />;
|
||||
@@ -413,12 +436,67 @@ function SingleTrackEditor() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<OptimizeCard trackId={trackId} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</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({
|
||||
match,
|
||||
onUse,
|
||||
@@ -589,3 +667,319 @@ function DiffView({
|
||||
</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,4 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Window,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
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,
|
||||
@@ -316,14 +320,60 @@ export function ScrobblingSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/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() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const instance = getActiveInstance();
|
||||
|
||||
const handleSignOut = () => {
|
||||
dispatch(logout());
|
||||
void navigate('/connect');
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title={t('settings.tabs.instance')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
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() {
|
||||
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 { play } from '../store/slices/player';
|
||||
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
||||
import { useGetSettingsQuery } from '../api/endpoints/settings';
|
||||
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
@@ -26,54 +27,75 @@ export function useAudioPlayer() {
|
||||
const player = useAppSelector((s) => s.player);
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||
const isSetup = useRef(false);
|
||||
// `ended` is registered once below; read the latest loop flag through a ref
|
||||
// so the listener doesn't need to be re-bound on every queue change.
|
||||
// Default stream quality (server-synced §A10) → the backend transcodes to it
|
||||
// on demand; a cache miss transparently falls back to the original.
|
||||
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);
|
||||
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(() => {
|
||||
if (isSetup.current) return;
|
||||
isSetup.current = true;
|
||||
|
||||
const audio = getAudio();
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
dispatch(setPosition(audio.currentTime));
|
||||
});
|
||||
audio.addEventListener('durationchange', () => {
|
||||
dispatch(setDuration(audio.duration || 0));
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
// Named handlers + cleanup so a remount (React StrictMode, or the hook
|
||||
// mounting elsewhere) can't leave duplicate listeners double-dispatching.
|
||||
const onTime = () => dispatch(setPosition(audio.currentTime));
|
||||
const onDuration = () => dispatch(setDuration(audio.duration || 0));
|
||||
const onEnded = () => {
|
||||
if (loopRef.current) {
|
||||
audio.currentTime = 0;
|
||||
void audio.play();
|
||||
} else {
|
||||
dispatch(nextTrack());
|
||||
}
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
dispatch(pause());
|
||||
});
|
||||
audio.addEventListener('play', () => {
|
||||
dispatch(resume());
|
||||
});
|
||||
};
|
||||
const onPause = () => dispatch(pause());
|
||||
const onPlay = () => dispatch(resume());
|
||||
|
||||
audio.addEventListener('timeupdate', onTime);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player.currentTrackId || !accessToken) return;
|
||||
const audio = getAudio();
|
||||
const url = getStreamUrl(player.currentTrackId, accessToken);
|
||||
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
||||
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.load();
|
||||
loadedTrackRef.current = player.currentTrackId;
|
||||
if (resumeAt > 0) {
|
||||
const onMeta = () => {
|
||||
audio.currentTime = resumeAt;
|
||||
audio.removeEventListener('loadedmetadata', onMeta);
|
||||
};
|
||||
audio.addEventListener('loadedmetadata', onMeta);
|
||||
}
|
||||
}
|
||||
if (player.isPlaying) {
|
||||
void audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, [player.currentTrackId, player.isPlaying, accessToken]);
|
||||
}, [player.currentTrackId, player.isPlaying, accessToken, streamQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
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',
|
||||
unmute: 'Unmute',
|
||||
},
|
||||
nowPlaying: {
|
||||
expand: 'Now playing',
|
||||
collapse: 'Collapse',
|
||||
artwork: 'Artwork',
|
||||
lyrics: 'Lyrics',
|
||||
},
|
||||
lyrics: {
|
||||
none: 'No lyrics found for this track.',
|
||||
refetch: 'Look again',
|
||||
},
|
||||
queue: {
|
||||
title: 'Play queue',
|
||||
shuffle: 'Shuffle queue',
|
||||
@@ -178,6 +188,13 @@ const en = {
|
||||
familiar: 'Familiar',
|
||||
new: 'New',
|
||||
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',
|
||||
removeFromQueue: 'Remove from queue',
|
||||
menu: {
|
||||
@@ -194,6 +211,7 @@ const en = {
|
||||
playNow: 'Play now',
|
||||
playNext: 'Play next',
|
||||
addToQueue: 'Add to queue',
|
||||
startRadio: 'Start radio',
|
||||
info: 'Track info',
|
||||
addToPlaylist: 'Add to playlist…',
|
||||
removeFromPlaylist: 'Remove from playlist',
|
||||
@@ -286,6 +304,36 @@ const en = {
|
||||
},
|
||||
storage: {
|
||||
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',
|
||||
server: 'On the server',
|
||||
audioCache: 'Cached audio',
|
||||
@@ -361,6 +409,12 @@ const en = {
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
},
|
||||
instance: {
|
||||
current: 'Current instance',
|
||||
none: 'No instance selected.',
|
||||
manage: 'Manage instances',
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
playback: {
|
||||
streamQuality: 'Streaming quality',
|
||||
streamQualityHint:
|
||||
@@ -400,6 +454,77 @@ const en = {
|
||||
sources: 'Sources',
|
||||
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: {
|
||||
title: 'Page not found',
|
||||
@@ -488,10 +613,35 @@ const en = {
|
||||
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: {
|
||||
error: 'Failed to load track',
|
||||
saved: 'Metadata saved.',
|
||||
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',
|
||||
fields: {
|
||||
title: 'Title',
|
||||
|
||||
@@ -164,6 +164,16 @@ const ru: Translations = {
|
||||
mute: 'Выключить звук',
|
||||
unmute: 'Включить звук',
|
||||
},
|
||||
nowPlaying: {
|
||||
expand: 'Сейчас играет',
|
||||
collapse: 'Свернуть',
|
||||
artwork: 'Обложка',
|
||||
lyrics: 'Текст',
|
||||
},
|
||||
lyrics: {
|
||||
none: 'Текст для этого трека не найден.',
|
||||
refetch: 'Искать снова',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь воспроизведения',
|
||||
shuffle: 'Перемешать очередь',
|
||||
@@ -179,6 +189,13 @@ const ru: Translations = {
|
||||
familiar: 'Знакомое',
|
||||
new: 'Новое',
|
||||
loadingMore: 'Загрузка радио…',
|
||||
whyPlaying: 'Почему это играет',
|
||||
reason: {
|
||||
ml: 'Рекомендация',
|
||||
similar: 'Похожее',
|
||||
from_likes: 'Из ваших лайков',
|
||||
discover: 'Что-то новое',
|
||||
},
|
||||
doubleClickPlay: 'Двойной клик для воспроизведения',
|
||||
removeFromQueue: 'Убрать из очереди',
|
||||
menu: {
|
||||
@@ -195,6 +212,7 @@ const ru: Translations = {
|
||||
playNow: 'Играть сейчас',
|
||||
playNext: 'Следующим',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
startRadio: 'Запустить радио',
|
||||
info: 'Информация о треке',
|
||||
addToPlaylist: 'Добавить в плейлист…',
|
||||
removeFromPlaylist: 'Убрать из плейлиста',
|
||||
@@ -287,6 +305,36 @@ const ru: Translations = {
|
||||
},
|
||||
storage: {
|
||||
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: 'На этом устройстве',
|
||||
server: 'На сервере',
|
||||
audioCache: 'Кэш аудио',
|
||||
@@ -362,6 +410,12 @@ const ru: Translations = {
|
||||
save: 'Сохранить',
|
||||
saving: 'Сохранение…',
|
||||
},
|
||||
instance: {
|
||||
current: 'Текущий инстанс',
|
||||
none: 'Инстанс не выбран.',
|
||||
manage: 'Управление инстансами',
|
||||
signOut: 'Выйти',
|
||||
},
|
||||
playback: {
|
||||
streamQuality: 'Качество потока',
|
||||
streamQualityHint:
|
||||
@@ -401,6 +455,77 @@ const ru: Translations = {
|
||||
sources: 'Источники',
|
||||
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: {
|
||||
title: 'Страница не найдена',
|
||||
@@ -490,7 +615,32 @@ const ru: Translations = {
|
||||
manual: 'Изменено вручную — не обновляется автоматически',
|
||||
},
|
||||
},
|
||||
metadataBatch: {
|
||||
selectedCount: 'Выбрано {{selected}} из {{total}}',
|
||||
status: {
|
||||
pending: 'Без метаданных',
|
||||
failed: 'С ошибкой',
|
||||
},
|
||||
result: 'Обновлено {{ok}}, ошибок {{fail}}',
|
||||
reEnrich: {
|
||||
title: 'Повторное авто-обогащение',
|
||||
hint: 'Поставить в очередь идентификацию AcoustID/MusicBrainz для выбранных треков.',
|
||||
button: 'Переобогатить выбранные',
|
||||
},
|
||||
common: {
|
||||
title: 'Применить общие поля',
|
||||
hint: 'Заполненные поля запишутся всем выбранным трекам; пустые останутся без изменений.',
|
||||
apply: 'Применить к выбранным',
|
||||
},
|
||||
empty: 'В этой категории пусто.',
|
||||
},
|
||||
metadataEditor: {
|
||||
optimize: {
|
||||
title: 'Оптимизировать формат',
|
||||
hint: 'Перекодировать в компактный Opus + HLS в фоне.',
|
||||
button: 'Оптимизировать',
|
||||
enqueued: 'Оптимизация поставлена в очередь — выполнится в фоне.',
|
||||
},
|
||||
error: 'Не удалось загрузить трек',
|
||||
saved: 'Метаданные сохранены.',
|
||||
saveError: 'Не удалось сохранить метаданные.',
|
||||
|
||||
@@ -23,6 +23,8 @@ import './api/endpoints/storage';
|
||||
import './api/endpoints/admin';
|
||||
import './api/endpoints/settings';
|
||||
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
|
||||
// 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;
|
||||
muted: boolean;
|
||||
isQueueOpen: boolean;
|
||||
isNowPlayingOpen: boolean;
|
||||
}
|
||||
|
||||
export const playerInitialState: PlayerState = {
|
||||
@@ -18,6 +19,7 @@ export const playerInitialState: PlayerState = {
|
||||
volume: 0.78,
|
||||
muted: false,
|
||||
isQueueOpen: false,
|
||||
isNowPlayingOpen: false,
|
||||
};
|
||||
|
||||
export const playerSlice = createSlice({
|
||||
@@ -55,6 +57,12 @@ export const playerSlice = createSlice({
|
||||
toggleQueue(state) {
|
||||
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,
|
||||
toggleMute,
|
||||
toggleQueue,
|
||||
toggleNowPlaying,
|
||||
setNowPlayingOpen,
|
||||
} = playerSlice.actions;
|
||||
export default playerSlice.reducer;
|
||||
|
||||
@@ -15,6 +15,15 @@ export interface QueueEntry {
|
||||
albumTitle: string;
|
||||
durationMs: number;
|
||||
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 {
|
||||
@@ -25,8 +34,16 @@ export interface QueueState {
|
||||
sourceName: string | null;
|
||||
shuffle: 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 = {
|
||||
entries: [],
|
||||
currentIndex: -1,
|
||||
@@ -35,6 +52,7 @@ export const queueInitialState: QueueState = {
|
||||
sourceName: null,
|
||||
shuffle: false,
|
||||
loop: false,
|
||||
radio: null,
|
||||
};
|
||||
|
||||
export const queueSlice = createSlice({
|
||||
@@ -56,6 +74,38 @@ export const queueSlice = createSlice({
|
||||
state.source = action.payload.source;
|
||||
state.sourceId = action.payload.sourceId ?? 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>) {
|
||||
state.entries.push(action.payload);
|
||||
@@ -102,6 +152,7 @@ export const queueSlice = createSlice({
|
||||
clearQueue(state) {
|
||||
state.entries = [];
|
||||
state.currentIndex = -1;
|
||||
state.radio = null;
|
||||
},
|
||||
toggleShuffle(state) {
|
||||
state.shuffle = !state.shuffle;
|
||||
@@ -114,6 +165,10 @@ export const queueSlice = createSlice({
|
||||
|
||||
export const {
|
||||
setQueue,
|
||||
startRadioQueue,
|
||||
appendToQueue,
|
||||
setExploration,
|
||||
stopRadio,
|
||||
addToQueue,
|
||||
addNextInQueue,
|
||||
playNow,
|
||||
|
||||
Reference in New Issue
Block a user