From 8a70f478c377bc5093b758d2ce931b3041c6a7c3 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sat, 13 Jun 2026 14:02:38 +0300 Subject: [PATCH] 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 --- src/api/mappers.ts | 3 + src/api/types.ts | 6 + src/components/common/Icon.tsx | 2 + src/components/layout/AppShell.tsx | 2 + src/components/player/PersistentPlayer.tsx | 9 +- src/components/player/QueuePanel.tsx | 24 +- src/components/track/TrackContextMenu.tsx | 33 ++- src/components/track/TrackInfoDrawer.tsx | 319 +++++++++++++++++++++ src/i18n/locales/en.ts | 37 +++ src/i18n/locales/ru.ts | 37 +++ src/lib/format.ts | 10 + src/store/slices/player.ts | 6 - src/store/slices/ui.ts | 11 + src/styles/shell.css | 158 ++++++++++ 14 files changed, 641 insertions(+), 16 deletions(-) create mode 100644 src/components/track/TrackInfoDrawer.tsx diff --git a/src/api/mappers.ts b/src/api/mappers.ts index bcbd6db..22c879d 100644 --- a/src/api/mappers.ts +++ b/src/api/mappers.ts @@ -130,6 +130,9 @@ export const toTrack = (r: RawTrack): Track => ({ liked: false, format: r.file_format, fileSize: r.file_size, + source: r.source, + createdAt: r.created_at, + enrichedAt: r.enriched_at ?? undefined, }); export const toAlbum = (r: RawAlbum): Album => ({ diff --git a/src/api/types.ts b/src/api/types.ts index 97d2b83..9d6cb26 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -30,6 +30,12 @@ export interface Track { format?: string; bitrate?: number; 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 { diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx index d2ce855..19fda55 100644 --- a/src/components/common/Icon.tsx +++ b/src/components/common/Icon.tsx @@ -19,6 +19,7 @@ import { GearSix, HardDrives, Heart, + Info, MagnifyingGlass, Pause, Play, @@ -69,6 +70,7 @@ const ICONS = { 'skip-forward': SkipForward, repeat: Repeat, heart: Heart, + info: Info, 'thumbs-down': ThumbsDown, 'speaker-high': SpeakerHigh, 'speaker-x': SpeakerSimpleX, diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4bccaf8..aef5ebc 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -3,6 +3,7 @@ import { Suspense } from 'react'; import { Sidebar } from './Sidebar'; import { PersistentPlayer } from '../player/PersistentPlayer'; import { QueuePanel } from '../player/QueuePanel'; +import { TrackInfoDrawer } from '../track/TrackInfoDrawer'; import { LoadingSkeleton } from '../common/LoadingSkeleton'; export function AppShell() { @@ -31,6 +32,7 @@ export function AppShell() { + diff --git a/src/components/player/PersistentPlayer.tsx b/src/components/player/PersistentPlayer.tsx index ee0f9e5..5ca7849 100644 --- a/src/components/player/PersistentPlayer.tsx +++ b/src/components/player/PersistentPlayer.tsx @@ -10,9 +10,9 @@ import { setVolume, toggleShuffle, setRepeat, - toggleNowPlaying, toggleQueue, } from '../../store/slices/player'; +import { openTrackInfo } from '../../store/slices/ui'; import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { useStreamCached } from '../../hooks/useStreamCached'; import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry'; @@ -49,8 +49,11 @@ export function PersistentPlayer() {
dispatch(toggleNowPlaying())} - style={{ cursor: 'pointer' }} + onClick={() => + currentEntry && dispatch(openTrackInfo(currentEntry.trackId)) + } + style={{ cursor: currentEntry ? 'pointer' : 'default' }} + title={currentEntry ? t('trackInfo.open') : undefined} >
diff --git a/src/components/player/QueuePanel.tsx b/src/components/player/QueuePanel.tsx index b24d1a4..a0f72a3 100644 --- a/src/components/player/QueuePanel.tsx +++ b/src/components/player/QueuePanel.tsx @@ -10,6 +10,7 @@ import { type QueueEntry, } from '../../store/slices/queue'; import { toggleQueue } from '../../store/slices/player'; +import { openTrackInfo } from '../../store/slices/ui'; import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry'; export function QueuePanel() { @@ -85,14 +86,27 @@ export function QueuePanel() {
{now.title}
{now.artistName}
- +
{isRadio && (
- + {t('queue.radioActive')}
@@ -161,7 +175,11 @@ function QueueRow({ const albumTitle = resolved?.albumTitle ?? entry.albumTitle; return ( -
+
diff --git a/src/components/track/TrackContextMenu.tsx b/src/components/track/TrackContextMenu.tsx index 41cd6f4..1704fd5 100644 --- a/src/components/track/TrackContextMenu.tsx +++ b/src/components/track/TrackContextMenu.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '../../hooks/useAppDispatch'; import { addToQueue, addNextInQueue } from '../../store/slices/queue'; import { play } from '../../store/slices/player'; +import { openTrackInfo } from '../../store/slices/ui'; import type { Track } from '../../api/types'; interface Props { @@ -42,21 +43,45 @@ export function TrackContextMenu({ return ( - + - { dispatch(play(track.id)); }}> + { + dispatch(play(track.id)); + }} + > {t('track.menu.playNow')} - { dispatch(addNextInQueue(entry)); }}> + { + dispatch(addNextInQueue(entry)); + }} + > {t('track.menu.playNext')} - { dispatch(addToQueue(entry)); }}> + { + dispatch(addToQueue(entry)); + }} + > {t('track.menu.addToQueue')} + { + dispatch(openTrackInfo(track.id)); + }} + > + {t('track.menu.info')} + + {onAddToPlaylist && ( onAddToPlaylist(track)}> {t('track.menu.addToPlaylist')} diff --git a/src/components/track/TrackInfoDrawer.tsx b/src/components/track/TrackInfoDrawer.tsx new file mode 100644 index 0000000..467876d --- /dev/null +++ b/src/components/track/TrackInfoDrawer.tsx @@ -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 ( + + ); +} + +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 ( + <> +
+

{t('trackInfo.title')}

+
+ +
+ +
+ {isLoading ? ( + + ) : isError ? ( + + ) : !track ? ( + + ) : ( + 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`); + }} + /> + )} +
+ + ); +} + +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 ( + <> +
+ {coverUrl ? ( + {track.albumTitle} + ) : ( + + )} +
+ +

{track.title}

+ + {track.artistName} + + {track.albumId && ( + + + {track.albumTitle} + + )} + +
+ + + +
+ + +
+ + + {track.liked && ( + + {t('trackInfo.liked')} + + )} +
+ {track.metadataStatus === 'failed' && track.metadataError && ( +

{track.metadataError}

+ )} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function InfoSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+ {title} + {children} +
+ ); +} + +/** 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 ( +
+ {label} + {value} +
+ ); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 69b461d..5f8c3d0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -156,12 +156,49 @@ const en = { playNow: 'Play now', playNext: 'Play next', addToQueue: 'Add to queue', + info: 'Track info', addToPlaylist: 'Add to playlist…', editMetadata: 'Edit metadata', download: 'Download', 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: { error: 'Something went wrong', retry: 'Retry', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 3653d5e..04a32b9 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -158,12 +158,49 @@ const ru: Translations = { playNow: 'Играть сейчас', playNext: 'Следующим', addToQueue: 'Добавить в очередь', + info: 'Информация о треке', addToPlaylist: 'Добавить в плейлист…', editMetadata: 'Редактировать метаданные', download: 'Скачать', 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: { error: 'Что-то пошло не так', retry: 'Повторить', diff --git a/src/lib/format.ts b/src/lib/format.ts index 5ce483f..5a2995b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -16,6 +16,16 @@ export function formatFileSize(bytes: number): string { 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 { if (n < 1000) return String(n); if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; diff --git a/src/store/slices/player.ts b/src/store/slices/player.ts index e90d252..0fbe02e 100644 --- a/src/store/slices/player.ts +++ b/src/store/slices/player.ts @@ -11,7 +11,6 @@ export interface PlayerState { muted: boolean; repeat: RepeatMode; shuffle: boolean; - isNowPlayingOpen: boolean; isQueueOpen: boolean; } @@ -24,7 +23,6 @@ export const playerInitialState: PlayerState = { muted: false, repeat: 'none', shuffle: false, - isNowPlayingOpen: false, isQueueOpen: false, }; @@ -66,9 +64,6 @@ export const playerSlice = createSlice({ toggleShuffle(state) { state.shuffle = !state.shuffle; }, - toggleNowPlaying(state) { - state.isNowPlayingOpen = !state.isNowPlayingOpen; - }, toggleQueue(state) { state.isQueueOpen = !state.isQueueOpen; }, @@ -86,7 +81,6 @@ export const { toggleMute, setRepeat, toggleShuffle, - toggleNowPlaying, toggleQueue, } = playerSlice.actions; export default playerSlice.reducer; diff --git a/src/store/slices/ui.ts b/src/store/slices/ui.ts index 3c62175..0c994c0 100644 --- a/src/store/slices/ui.ts +++ b/src/store/slices/ui.ts @@ -4,12 +4,15 @@ interface UiState { sidebarCollapsed: boolean; activeModal: string | null; activeTrackContextMenuId: string | null; + /** Track whose info drawer is open (rightmost drawer); null = closed. */ + trackInfoId: string | null; } const initialState: UiState = { sidebarCollapsed: false, activeModal: null, activeTrackContextMenuId: null, + trackInfoId: null, }; export const uiSlice = createSlice({ @@ -31,6 +34,12 @@ export const uiSlice = createSlice({ setActiveContextMenu(state, action: PayloadAction) { state.activeTrackContextMenuId = action.payload; }, + openTrackInfo(state, action: PayloadAction) { + state.trackInfoId = action.payload; + }, + closeTrackInfo(state) { + state.trackInfoId = null; + }, }, }); @@ -40,5 +49,7 @@ export const { openModal, closeModal, setActiveContextMenu, + openTrackInfo, + closeTrackInfo, } = uiSlice.actions; export default uiSlice.reducer; diff --git a/src/styles/shell.css b/src/styles/shell.css index 1215e13..3e5cdc2 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -724,6 +724,164 @@ 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) ============================================================ */