feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-22 09:53:37 +03:00
1 parent 39aaaed969
commit 72567acd28
31 files changed
+939 -131

No files matched your search

+164 -5
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
@@ -37,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
@@ -97,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
return (
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div
className={s.now}
onClick={
narrow
? (e) => {
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
}
: undefined
}
>
{narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button>
) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)}
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.artists}>
@@ -232,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{sheetOpen && narrow && (
<NowPlayingSheet
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
function useNarrow() {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 767px)');
if (!mq) return;
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener?.('change', sync);
return () => mq.removeEventListener?.('change', sync);
}, []);
return narrow;
}
function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!track) return null;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
return createPortal(
<div className={s.sheetScrim} onClick={onClose}>
<div
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
role="dialog"
aria-modal="true"
aria-label={t('player.now_playing')}
onClick={(e) => e.stopPropagation()}
>
<div className={s.sheetHead}>
<span>{t('player.now_playing')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
<div className={s.sheetMeta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</div>
<div className={s.progress}>
<span className={s.time}>{formatDuration(st.positionMs)}</span>
<input
type="range"
className={s.range}
min={0}
max={st.durationMs || track.duration_ms}
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
</div>
<div className={s.sheetControls}>
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</button>
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.sheetExtra}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
<Icon src={shuffleIcon} size={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
</button>
<input
type="range"
className={cx(s.range, s.volume)}
min={0}
max={1}
step={0.01}
value={st.muted ? 0 : st.volume}
aria-label={t('player.volume')}
onChange={(e) => player.setVolume(Number(e.target.value))}
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
</div>
</div>,
document.body,
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
@@ -256,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<>
<div className={s.queueScrim} onClick={onClose} />
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
@@ -282,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
</aside>
</>,
document.body,
);
}