152 lines
5.1 KiB
TypeScript
152 lines
5.1 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,
|
|
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;
|
|
const formatLabel = current?.format?.toUpperCase();
|
|
|
|
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')}
|
|
{formatLabel && ` · ${formatLabel}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pl-center">
|
|
<div className="pl-transport">
|
|
<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>
|
|
</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>
|
|
);
|
|
}
|