feat: track info drawer (Get Info-style)

Add a right-side track info drawer that sits to the right of the queue
panel when both are open. Shows a large cover, title/artist/album links,
a Play/Queue/Edit actions row, and Status/General/File/Identifiers
sections (empty rows omitted). Opens from the track context menu, the
player now-playing tile, and the queue now-playing card.

- ui slice: trackInfoId + open/closeTrackInfo
- TrackInfoDrawer rendered after QueuePanel in AppShell; overlays content
  on narrow viewports
- map source/createdAt/enrichedAt from the wire (were unmapped)
- formatDateTime helper, info icon, i18n (en/ru)
- drop orphaned toggleNowPlaying/isNowPlayingOpen from player slice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-13 14:02:38 +03:00
parent 9c344b98c4
commit 8a70f478c3
14 changed files with 641 additions and 16 deletions
+319
View File
@@ -0,0 +1,319 @@
import type { ReactNode } from 'react';
import { Badge, Button } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { skipToken } from '@reduxjs/toolkit/query';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { LoadingSkeleton } from '../common/LoadingSkeleton';
import { ErrorState } from '../common/ErrorState';
import { EmptyState } from '../common/EmptyState';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { closeTrackInfo } from '../../store/slices/ui';
import { play } from '../../store/slices/player';
import { addToQueue } from '../../store/slices/queue';
import {
useGetTrackQuery,
useGetAlbumQuery,
} from '../../api/endpoints/library';
import { getTrackCoverUrl } from '../../api/endpoints/streaming';
import {
formatDuration,
formatFileSize,
formatDateTime,
} from '../../lib/format';
import type { Track } from '../../api/types';
/**
* Right-side "Get Info"-style drawer for a single track. Rendered after the
* QueuePanel in AppShell so it sits to the *right* of the queue when both are
* open. Open state lives in `ui.trackInfoId`; it reads the live Track (and its
* album) from the RTKQ cache so enrichment updates stay in sync.
*/
export function TrackInfoDrawer() {
const trackId = useAppSelector((s) => s.ui.trackInfoId);
const isOpen = trackId !== null;
return (
<aside className={`tid${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="tid-inner">
{trackId ? <TrackInfoContent trackId={trackId} /> : null}
</div>
</aside>
);
}
function TrackInfoContent({ trackId }: { trackId: string }) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const token = useAppSelector((s) => s.auth.accessToken);
const {
data: track,
isLoading,
isError,
refetch,
} = useGetTrackQuery(trackId);
// Album record fills in fields the lean TrackOut omits (year especially).
const { data: album } = useGetAlbumQuery(track?.albumId ?? skipToken);
const close = () => dispatch(closeTrackInfo());
return (
<>
<div className="tid-head">
<h3>{t('trackInfo.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={close}
title={t('trackInfo.close')}
>
<Icon name="x" />
</button>
</div>
<div className="tid-scroll">
{isLoading ? (
<LoadingSkeleton rows={6} />
) : isError ? (
<ErrorState onRetry={refetch} />
) : !track ? (
<EmptyState title={t('trackInfo.notFound')} />
) : (
<TrackInfoBody
track={track}
albumYear={album?.year}
albumTrackCount={album?.trackCount}
coverUrl={
token
? getTrackCoverUrl(track.id, token, track.hasCover)
: undefined
}
onPlay={() => dispatch(play(track.id))}
onQueue={() =>
dispatch(
addToQueue({
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
}),
)
}
onEdit={() => {
navigate(`/tracks/${track.id}/metadata`);
}}
/>
)}
</div>
</>
);
}
function TrackInfoBody({
track,
albumYear,
albumTrackCount,
coverUrl,
onPlay,
onQueue,
onEdit,
}: {
track: Track;
albumYear?: number;
albumTrackCount?: number;
coverUrl?: string;
onPlay: () => void;
onQueue: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const seedLabel = track.albumTitle || track.title;
const year = track.year ?? albumYear;
return (
<>
<div className="tid-cover">
{coverUrl ? (
<img src={coverUrl} alt={track.albumTitle} />
) : (
<ArtTile seed={seedLabel} size={256} label={seedLabel} radius={12} />
)}
</div>
<h2 className="tid-title">{track.title}</h2>
<Link className="tid-sub" to={`/artists/${track.artistId}`}>
{track.artistName}
</Link>
{track.albumId && (
<Link className="tid-sub tid-album" to={`/albums/${track.albumId}`}>
<Icon name="vinyl-record" />
{track.albumTitle}
</Link>
)}
<div className="tid-actions">
<Button variant="primary" size="sm" onClick={onPlay}>
<Icon name="play" fill /> {t('trackInfo.play')}
</Button>
<Button variant="ghost" size="sm" onClick={onQueue}>
<Icon name="queue" /> {t('trackInfo.addToQueue')}
</Button>
<Button variant="ghost" size="sm" onClick={onEdit}>
{t('trackInfo.editMetadata')}
</Button>
</div>
<InfoSection title={t('trackInfo.sections.status')}>
<div className="tid-status">
<AvailabilityBadge availability={track.availability} />
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
hideWhenEnriched={false}
/>
{track.liked && (
<Badge variant="lime" dot>
{t('trackInfo.liked')}
</Badge>
)}
</div>
{track.metadataStatus === 'failed' && track.metadataError && (
<p className="tid-error">{track.metadataError}</p>
)}
</InfoSection>
<InfoSection title={t('trackInfo.sections.general')}>
<InfoRow
label={t('trackInfo.fields.artist')}
value={track.artistName}
/>
<InfoRow
label={t('trackInfo.fields.album')}
value={track.albumTitle || undefined}
/>
<InfoRow
label={t('trackInfo.fields.trackNumber')}
value={
track.trackNumber !== undefined
? albumTrackCount
? t('trackInfo.trackOf', {
n: track.trackNumber,
total: albumTrackCount,
})
: String(track.trackNumber)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.disc')}
value={
track.discNumber !== undefined
? String(track.discNumber)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.year')}
value={year !== undefined ? String(year) : undefined}
/>
<InfoRow label={t('trackInfo.fields.genre')} value={track.genre} />
<InfoRow
label={t('trackInfo.fields.duration')}
value={formatDuration(track.durationMs)}
/>
</InfoSection>
<InfoSection title={t('trackInfo.sections.file')}>
<InfoRow
label={t('trackInfo.fields.format')}
value={track.format?.toUpperCase()}
/>
<InfoRow
label={t('trackInfo.fields.bitrate')}
value={
track.bitrate !== undefined
? t('trackInfo.kbps', { n: track.bitrate })
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.size')}
value={
track.fileSize !== undefined
? formatFileSize(track.fileSize)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.source')}
value={track.source}
mono
/>
<InfoRow
label={t('trackInfo.fields.added')}
value={formatDateTime(track.createdAt)}
/>
<InfoRow
label={t('trackInfo.fields.enriched')}
value={formatDateTime(track.enrichedAt)}
/>
</InfoSection>
<InfoSection title={t('trackInfo.sections.identifiers')}>
<InfoRow label={t('trackInfo.fields.trackId')} value={track.id} mono />
<InfoRow
label={t('trackInfo.fields.albumId')}
value={track.albumId || undefined}
mono
/>
<InfoRow
label={t('trackInfo.fields.artistId')}
value={track.artistId}
mono
/>
</InfoSection>
</>
);
}
function InfoSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section className="tid-section">
<span className="msk-label tid-section-label">{title}</span>
{children}
</section>
);
}
/** A label/value row; renders nothing when the value is empty (Finder-style). */
function InfoRow({
label,
value,
mono,
}: {
label: string;
value?: string;
mono?: boolean;
}) {
if (!value) return null;
return (
<div className="tid-row">
<span className="tid-row-k">{label}</span>
<span className={`tid-row-v${mono ? ' mono' : ''}`}>{value}</span>
</div>
);
}