Compare commits

6 Commits

Author SHA1 Message Date
Цвылев Александр Вадимович e45ef785cf feat(player): stream at the chosen quality + per-track optimize (§6.6)
The Playback quality setting now drives streaming: getStreamUrl appends
?quality= and useAudioPlayer feeds the server-synced streamQuality, so the
backend transcodes on demand (miss → original). Adds an optimizeTrack mutation
and an Optimize action in the metadata editor to warm a track's rendition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 21:22:59 +03:00
Цвылев Александр Вадимович 9d88d70be0 feat(player): Now Playing overlay + synced lyrics panel (A1/A11, §6.7)
Full-screen overlay expanded from the persistent player: big artwork or a
Lyrics tab. Synced (LRC) lyrics highlight the active line by playback position,
auto-scroll, and seek on tap; plain text is the fallback; missing lyrics offers
a re-fetch. Adds lyrics endpoint + LRC parser + isNowPlayingOpen player state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 21:04:39 +03:00
Цвылев Александр Вадимович 6efbf4b68b feat(metadata): wire batch editor over metadata-status buckets (A7)
/metadata/batch?status=pending|failed edits a whole bucket: bulk re-enrich
(AcoustID/MusicBrainz) and stamp common fields onto the selection, with a
select-list and per-track checkboxes. Storage Maintenance failed/missing tabs
link straight into it, closing the A6→A7 flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:50:04 +03:00
Цвылев Александр Вадимович 87fac7a5a1 feat(admin): wire instance health panel + settings instance (A9/A10)
AdminInstance shows backing-service health (db/redis/ml), read-only source
status (env-configured), and a library reindex with graceful handling when no
indexable source exists. InstanceSettings shows the active instance and offers
switch/sign-out to /connect. Adds getServices/getSources/reindex endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:43:27 +03:00
Цвылев Александр Вадимович 3eba3701d0 feat(storage): wire Storage Maintenance screen (A6)
Duplicates / failed / missing-metadata tabs (paginated, reusing TrackRow) plus
admin orphan-file cleanup. Adds getDuplicates/getBrokenFiles/getMissingMetadata/
runCleanup endpoints and a DuplicateGroup type; drops the dead scanStorage/
deleteTrackFile stubs that had no backend route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:42:05 +03:00
Цвылев Александр Вадимович 28d9e59e34 feat(admin): wire Admin Users management (A9)
User list with create dialog, role toggle, (de)activate, and reset-password;
self-lockout guard. Adds getUser/resetPassword endpoints, an isActive field on
the User type, and en/ru strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:40:36 +03:00
21 changed files with 2214 additions and 43 deletions
+41 -3
View File
@@ -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;
+13
View File
@@ -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;
+49
View File
@@ -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;
+54 -14
View File
@@ -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;
+10 -2
View File
@@ -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 {
+1
View File
@@ -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,
});
+28
View File
@@ -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,29 @@ export interface StorageStats {
disk?: StorageDiskUsage;
}
/** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */
export interface DuplicateGroup {
fingerprint: string;
tracks: Track[];
}
/** 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
View File
@@ -2,6 +2,7 @@ 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 { TrackInfoDrawer } from '../track/TrackInfoDrawer';
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
@@ -37,6 +38,7 @@ export function AppShell() {
</div>
<AddToPlaylistDialog />
<PersistentPlayer />
<NowPlaying />
</div>
);
}
+169
View File
@@ -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>
);
}
+188
View File
@@ -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>
);
+622 -8
View File
@@ -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. */
@@ -60,13 +67,12 @@ function fieldStyle(): React.CSSProperties {
/**
* `/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 +419,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 +650,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 Promise.allSettled(ids.map(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>
);
}
+54 -4
View File
@@ -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>
);
}
+238 -3
View File
@@ -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>
);
}
+7 -2
View File
@@ -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,6 +27,10 @@ export function useAudioPlayer() {
const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken);
// 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;
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.
@@ -63,7 +68,7 @@ export function useAudioPlayer() {
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) {
audio.src = url;
audio.load();
@@ -73,7 +78,7 @@ export function useAudioPlayer() {
} else {
audio.pause();
}
}, [player.currentTrackId, player.isPlaying, accessToken]);
}, [player.currentTrackId, player.isPlaying, accessToken, streamQuality]);
useEffect(() => {
const audio = getAudio();
+142
View File
@@ -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',
@@ -286,6 +296,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 +401,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 +446,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 +605,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',
+142
View File
@@ -164,6 +164,16 @@ const ru: Translations = {
mute: 'Выключить звук',
unmute: 'Включить звук',
},
nowPlaying: {
expand: 'Сейчас играет',
collapse: 'Свернуть',
artwork: 'Обложка',
lyrics: 'Текст',
},
lyrics: {
none: 'Текст для этого трека не найден.',
refetch: 'Искать снова',
},
queue: {
title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь',
@@ -287,6 +297,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 +402,12 @@ const ru: Translations = {
save: 'Сохранить',
saving: 'Сохранение…',
},
instance: {
current: 'Текущий инстанс',
none: 'Инстанс не выбран.',
manage: 'Управление инстансами',
signOut: 'Выйти',
},
playback: {
streamQuality: 'Качество потока',
streamQualityHint:
@@ -401,6 +447,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 +607,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: 'Не удалось сохранить метаданные.',
+1
View File
@@ -23,6 +23,7 @@ import './api/endpoints/storage';
import './api/endpoints/admin';
import './api/endpoints/settings';
import './api/endpoints/upload';
import './api/endpoints/lyrics';
// Tier 3 offline: register the audio-caching service worker (no-op if the
// browser/origin doesn't support it).
+50
View File
@@ -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;
}
+10
View File
@@ -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;