Files
mcma-webui/src/components/player/PersistentPlayer.tsx
T
Senko-san 8a70f478c3 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>
2026-06-13 14:02:38 +03:00

178 lines
5.8 KiB
TypeScript

import { Slider } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import {
pause,
resume,
toggleMute,
setVolume,
toggleShuffle,
setRepeat,
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';
import { formatDuration } from '../../lib/format';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue);
const token = useAppSelector((s) => s.auth.accessToken);
const currentEntry = queue.entries[queue.currentIndex];
// Read through to the live Track cache so enrichment updates reach the player,
// not just the play-time snapshot frozen in the queue slice.
const current = useResolvedQueueEntry(currentEntry);
// Source indicator: cached → playing locally, otherwise streaming.
const cached = useStreamCached(currentEntry?.trackId);
if (!currentEntry && !player.currentTrackId) {
return <div className="player empty">{t('player.nothingPlaying')}</div>;
}
const artUrl =
getCoverUrl(currentEntry?.albumArtUrl) ??
(token && current?.hasCover
? getTrackCoverUrl(current.trackId, token, true)
: undefined);
const seedLabel = current?.albumTitle ?? current?.title ?? '';
const onStream = !cached;
return (
<div className="player">
<div
className="pl-now"
onClick={() =>
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} />
<div className="pl-now-tt">
<div className="t">{current?.title ?? '—'}</div>
<div className="a">{current?.artistName ?? ''}</div>
<div
className="pl-srcbadge"
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? t('player.streaming') : t('player.local')}
</div>
</div>
</div>
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playPrev}
title={t('player.previous')}
>
<Icon name="skip-back" fill />
</button>
<button
type="button"
className="pl-play"
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? t('player.pause') : t('player.play')}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playNext}
title={t('player.next')}
>
<Icon name="skip-forward" fill />
</button>
<button
type="button"
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
onClick={() =>
dispatch(
setRepeat(
player.repeat === 'none'
? 'all'
: player.repeat === 'all'
? 'one'
: 'none',
),
)
}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
</div>
<div className="pl-seek">
<span className="pl-time">
{formatDuration(player.position * 1000)}
</span>
<Slider
className="pl-seek-slider"
min={0}
max={player.duration || 1}
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
aria-label={t('player.play')}
/>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
</span>
</div>
</div>
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
onClick={() => dispatch(toggleMute())}
title={player.muted ? t('player.unmute') : t('player.mute')}
>
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button>
<div className="pl-vol">
<Slider
className="pl-vol-slider"
min={0}
max={1}
step={0.01}
value={[player.muted ? 0 : player.volume]}
onValueChange={([v]) => dispatch(setVolume(v))}
aria-label="Volume"
/>
</div>
<button
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
title={t('player.queue')}
>
<Icon name="queue" />
</button>
</div>
</div>
);
}