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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user