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:
@@ -130,6 +130,9 @@ export const toTrack = (r: RawTrack): Track => ({
|
|||||||
liked: false,
|
liked: false,
|
||||||
format: r.file_format,
|
format: r.file_format,
|
||||||
fileSize: r.file_size,
|
fileSize: r.file_size,
|
||||||
|
source: r.source,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
enrichedAt: r.enriched_at ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toAlbum = (r: RawAlbum): Album => ({
|
export const toAlbum = (r: RawAlbum): Album => ({
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export interface Track {
|
|||||||
format?: string;
|
format?: string;
|
||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
|
/** Where the track entered the library (e.g. `upload`, `local_folder`). */
|
||||||
|
source?: string;
|
||||||
|
/** ISO timestamp the track was added to the library. */
|
||||||
|
createdAt?: string;
|
||||||
|
/** ISO timestamp the last successful enrichment ran; undefined if never. */
|
||||||
|
enrichedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
GearSix,
|
GearSix,
|
||||||
HardDrives,
|
HardDrives,
|
||||||
Heart,
|
Heart,
|
||||||
|
Info,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
@@ -69,6 +70,7 @@ const ICONS = {
|
|||||||
'skip-forward': SkipForward,
|
'skip-forward': SkipForward,
|
||||||
repeat: Repeat,
|
repeat: Repeat,
|
||||||
heart: Heart,
|
heart: Heart,
|
||||||
|
info: Info,
|
||||||
'thumbs-down': ThumbsDown,
|
'thumbs-down': ThumbsDown,
|
||||||
'speaker-high': SpeakerHigh,
|
'speaker-high': SpeakerHigh,
|
||||||
'speaker-x': SpeakerSimpleX,
|
'speaker-x': SpeakerSimpleX,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||||
import { QueuePanel } from '../player/QueuePanel';
|
import { QueuePanel } from '../player/QueuePanel';
|
||||||
|
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -31,6 +32,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<QueuePanel />
|
<QueuePanel />
|
||||||
|
<TrackInfoDrawer />
|
||||||
</div>
|
</div>
|
||||||
<PersistentPlayer />
|
<PersistentPlayer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
setVolume,
|
setVolume,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
setRepeat,
|
setRepeat,
|
||||||
toggleNowPlaying,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} from '../../store/slices/player';
|
} from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
import { useStreamCached } from '../../hooks/useStreamCached';
|
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||||
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
@@ -49,8 +49,11 @@ export function PersistentPlayer() {
|
|||||||
<div className="player">
|
<div className="player">
|
||||||
<div
|
<div
|
||||||
className="pl-now"
|
className="pl-now"
|
||||||
onClick={() => dispatch(toggleNowPlaying())}
|
onClick={() =>
|
||||||
style={{ cursor: 'pointer' }}
|
currentEntry && dispatch(openTrackInfo(currentEntry.trackId))
|
||||||
|
}
|
||||||
|
style={{ cursor: currentEntry ? 'pointer' : 'default' }}
|
||||||
|
title={currentEntry ? t('trackInfo.open') : undefined}
|
||||||
>
|
>
|
||||||
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
||||||
<div className="pl-now-tt">
|
<div className="pl-now-tt">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type QueueEntry,
|
type QueueEntry,
|
||||||
} from '../../store/slices/queue';
|
} from '../../store/slices/queue';
|
||||||
import { toggleQueue } from '../../store/slices/player';
|
import { toggleQueue } from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
|
|
||||||
export function QueuePanel() {
|
export function QueuePanel() {
|
||||||
@@ -85,14 +86,27 @@ export function QueuePanel() {
|
|||||||
<div className="t">{now.title}</div>
|
<div className="t">{now.title}</div>
|
||||||
<div className="r">{now.artistName}</div>
|
<div className="r">{now.artistName}</div>
|
||||||
</div>
|
</div>
|
||||||
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn sm"
|
||||||
|
onClick={() => dispatch(openTrackInfo(now.trackId))}
|
||||||
|
title={t('trackInfo.open')}
|
||||||
|
>
|
||||||
|
<Icon name="info" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
<div className="qd-radio">
|
<div className="qd-radio">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<Icon name="radio" />
|
<Icon name="radio" />
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('queue.radioActive')}
|
{t('queue.radioActive')}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
@@ -161,7 +175,11 @@ function QueueRow({
|
|||||||
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="qrow" onDoubleClick={onPlay} title={t('queue.doubleClickPlay')}>
|
<div
|
||||||
|
className="qrow"
|
||||||
|
onDoubleClick={onPlay}
|
||||||
|
title={t('queue.doubleClickPlay')}
|
||||||
|
>
|
||||||
<span className="grip">
|
<span className="grip">
|
||||||
<Icon name="dots-six-vertical" />
|
<Icon name="dots-six-vertical" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -42,21 +43,45 @@ export function TrackContextMenu({
|
|||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
<IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}>
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('track.menu.options')}
|
||||||
|
>
|
||||||
⋯
|
⋯
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
<MenuContent>
|
<MenuContent>
|
||||||
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(play(track.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('track.menu.playNow')}
|
{t('track.menu.playNow')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(addNextInQueue(entry));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('track.menu.playNext')}
|
{t('track.menu.playNext')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(addToQueue(entry));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('track.menu.addToQueue')}
|
{t('track.menu.addToQueue')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(openTrackInfo(track.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('track.menu.info')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
{onAddToPlaylist && (
|
{onAddToPlaylist && (
|
||||||
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
||||||
{t('track.menu.addToPlaylist')}
|
{t('track.menu.addToPlaylist')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -156,12 +156,49 @@ const en = {
|
|||||||
playNow: 'Play now',
|
playNow: 'Play now',
|
||||||
playNext: 'Play next',
|
playNext: 'Play next',
|
||||||
addToQueue: 'Add to queue',
|
addToQueue: 'Add to queue',
|
||||||
|
info: 'Track info',
|
||||||
addToPlaylist: 'Add to playlist…',
|
addToPlaylist: 'Add to playlist…',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
trackInfo: {
|
||||||
|
title: 'Track info',
|
||||||
|
open: 'View track info',
|
||||||
|
close: 'Close',
|
||||||
|
notFound: 'Track not found',
|
||||||
|
play: 'Play',
|
||||||
|
addToQueue: 'Queue',
|
||||||
|
editMetadata: 'Edit metadata',
|
||||||
|
liked: 'Liked',
|
||||||
|
trackOf: 'No. {{n}} of {{total}}',
|
||||||
|
kbps: '{{n}} kbps',
|
||||||
|
sections: {
|
||||||
|
status: 'Status',
|
||||||
|
general: 'General',
|
||||||
|
file: 'File',
|
||||||
|
identifiers: 'Identifiers',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
artist: 'Artist',
|
||||||
|
album: 'Album',
|
||||||
|
trackNumber: 'Track',
|
||||||
|
disc: 'Disc',
|
||||||
|
year: 'Year',
|
||||||
|
genre: 'Genre',
|
||||||
|
duration: 'Duration',
|
||||||
|
format: 'Format',
|
||||||
|
bitrate: 'Bitrate',
|
||||||
|
size: 'Size',
|
||||||
|
source: 'Source',
|
||||||
|
added: 'Added',
|
||||||
|
enriched: 'Enriched',
|
||||||
|
trackId: 'Track ID',
|
||||||
|
albumId: 'Album ID',
|
||||||
|
artistId: 'Artist ID',
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
error: 'Something went wrong',
|
error: 'Something went wrong',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
|||||||
@@ -158,12 +158,49 @@ const ru: Translations = {
|
|||||||
playNow: 'Играть сейчас',
|
playNow: 'Играть сейчас',
|
||||||
playNext: 'Следующим',
|
playNext: 'Следующим',
|
||||||
addToQueue: 'Добавить в очередь',
|
addToQueue: 'Добавить в очередь',
|
||||||
|
info: 'Информация о треке',
|
||||||
addToPlaylist: 'Добавить в плейлист…',
|
addToPlaylist: 'Добавить в плейлист…',
|
||||||
editMetadata: 'Редактировать метаданные',
|
editMetadata: 'Редактировать метаданные',
|
||||||
download: 'Скачать',
|
download: 'Скачать',
|
||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
trackInfo: {
|
||||||
|
title: 'О треке',
|
||||||
|
open: 'Информация о треке',
|
||||||
|
close: 'Закрыть',
|
||||||
|
notFound: 'Трек не найден',
|
||||||
|
play: 'Играть',
|
||||||
|
addToQueue: 'В очередь',
|
||||||
|
editMetadata: 'Метаданные',
|
||||||
|
liked: 'В избранном',
|
||||||
|
trackOf: '№ {{n}} из {{total}}',
|
||||||
|
kbps: '{{n}} кбит/с',
|
||||||
|
sections: {
|
||||||
|
status: 'Статус',
|
||||||
|
general: 'Основное',
|
||||||
|
file: 'Файл',
|
||||||
|
identifiers: 'Идентификаторы',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
artist: 'Исполнитель',
|
||||||
|
album: 'Альбом',
|
||||||
|
trackNumber: 'Трек',
|
||||||
|
disc: 'Диск',
|
||||||
|
year: 'Год',
|
||||||
|
genre: 'Жанр',
|
||||||
|
duration: 'Длительность',
|
||||||
|
format: 'Формат',
|
||||||
|
bitrate: 'Битрейт',
|
||||||
|
size: 'Размер',
|
||||||
|
source: 'Источник',
|
||||||
|
added: 'Добавлен',
|
||||||
|
enriched: 'Обогащён',
|
||||||
|
trackId: 'ID трека',
|
||||||
|
albumId: 'ID альбома',
|
||||||
|
artistId: 'ID исполнителя',
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
error: 'Что-то пошло не так',
|
error: 'Что-то пошло не так',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string | undefined): string | undefined {
|
||||||
|
if (!iso) return undefined;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return undefined;
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatCount(n: number): string {
|
export function formatCount(n: number): string {
|
||||||
if (n < 1000) return String(n);
|
if (n < 1000) return String(n);
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface PlayerState {
|
|||||||
muted: boolean;
|
muted: boolean;
|
||||||
repeat: RepeatMode;
|
repeat: RepeatMode;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
isNowPlayingOpen: boolean;
|
|
||||||
isQueueOpen: boolean;
|
isQueueOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ export const playerInitialState: PlayerState = {
|
|||||||
muted: false,
|
muted: false,
|
||||||
repeat: 'none',
|
repeat: 'none',
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
isNowPlayingOpen: false,
|
|
||||||
isQueueOpen: false,
|
isQueueOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,9 +64,6 @@ export const playerSlice = createSlice({
|
|||||||
toggleShuffle(state) {
|
toggleShuffle(state) {
|
||||||
state.shuffle = !state.shuffle;
|
state.shuffle = !state.shuffle;
|
||||||
},
|
},
|
||||||
toggleNowPlaying(state) {
|
|
||||||
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
|
||||||
},
|
|
||||||
toggleQueue(state) {
|
toggleQueue(state) {
|
||||||
state.isQueueOpen = !state.isQueueOpen;
|
state.isQueueOpen = !state.isQueueOpen;
|
||||||
},
|
},
|
||||||
@@ -86,7 +81,6 @@ export const {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
setRepeat,
|
setRepeat,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
toggleNowPlaying,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} = playerSlice.actions;
|
} = playerSlice.actions;
|
||||||
export default playerSlice.reducer;
|
export default playerSlice.reducer;
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ interface UiState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
activeModal: string | null;
|
activeModal: string | null;
|
||||||
activeTrackContextMenuId: string | null;
|
activeTrackContextMenuId: string | null;
|
||||||
|
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
||||||
|
trackInfoId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UiState = {
|
const initialState: UiState = {
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
activeTrackContextMenuId: null,
|
activeTrackContextMenuId: null,
|
||||||
|
trackInfoId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uiSlice = createSlice({
|
export const uiSlice = createSlice({
|
||||||
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
|
|||||||
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
||||||
state.activeTrackContextMenuId = action.payload;
|
state.activeTrackContextMenuId = action.payload;
|
||||||
},
|
},
|
||||||
|
openTrackInfo(state, action: PayloadAction<string>) {
|
||||||
|
state.trackInfoId = action.payload;
|
||||||
|
},
|
||||||
|
closeTrackInfo(state) {
|
||||||
|
state.trackInfoId = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,5 +49,7 @@ export const {
|
|||||||
openModal,
|
openModal,
|
||||||
closeModal,
|
closeModal,
|
||||||
setActiveContextMenu,
|
setActiveContextMenu,
|
||||||
|
openTrackInfo,
|
||||||
|
closeTrackInfo,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
|
|||||||
@@ -724,6 +724,164 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TRACK INFO DRAWER (rightmost — sits right of the queue drawer)
|
||||||
|
============================================================ */
|
||||||
|
/* Same width-collapse pattern as .qd. Rendered after QueuePanel in AppShell so
|
||||||
|
when both are open this is the rightmost panel. */
|
||||||
|
.tid {
|
||||||
|
width: 360px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid var(--hair);
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.24));
|
||||||
|
transition:
|
||||||
|
width 0.24s var(--ease-out),
|
||||||
|
border-left-color 0.24s var(--ease-out);
|
||||||
|
}
|
||||||
|
.tid.closed {
|
||||||
|
width: 0;
|
||||||
|
border-left-color: transparent;
|
||||||
|
}
|
||||||
|
.tid-inner {
|
||||||
|
width: 360px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.tid-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
border-bottom: 1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.tid-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.tid-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.tid-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--steel-900);
|
||||||
|
box-shadow: var(--shadow-raised, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||||
|
}
|
||||||
|
.tid-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.tid-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg-1);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.tid-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tid-sub:hover {
|
||||||
|
color: var(--lime);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.tid-album {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.tid-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
.tid-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.tid-section-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tid-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tid-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ember, #e9572b);
|
||||||
|
}
|
||||||
|
.tid-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tid-row-k {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 96px;
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.tid-row-v {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--fg-1);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.tid-row-v.mono {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On narrower viewports the drawer overlays the content instead of pushing it,
|
||||||
|
so the queue + info drawers don't squeeze the main screen. */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.app-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tid {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 360px;
|
||||||
|
z-index: 30;
|
||||||
|
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: transform 0.24s var(--ease-out);
|
||||||
|
}
|
||||||
|
.tid.closed {
|
||||||
|
width: 360px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PAGE HEADER + SECONDARY NAV (Settings, Admin)
|
PAGE HEADER + SECONDARY NAV (Settings, Admin)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
Reference in New Issue
Block a user