8a70f478c3
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>
320 lines
9.1 KiB
TypeScript
320 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|