feat(web): align the web UI with the design prototype
- Ambient background in the app shell: three drifting blurred blobs,
the third one tinted with the current track's cover; can be turned off
in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
in settings; player source line ("Album · …", "My Wave"), reactions
around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
lead column with an equalizer for the current track, album column,
dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
Play and popular tracks (new GET /artists/{id}/top-tracks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3afed02f23
commit
1c4e209e86
29 files changed
+1381
-339
No files matched your search
@@ -1,10 +1,14 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import {
|
||||
albumIcon,
|
||||
closeIcon,
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
likeFillIcon,
|
||||
@@ -14,25 +18,28 @@ import {
|
||||
pauseIcon,
|
||||
playIcon,
|
||||
prevIcon,
|
||||
queueIcon,
|
||||
repeatIcon,
|
||||
repeatOneIcon,
|
||||
shuffleIcon,
|
||||
volumeIcon,
|
||||
volumeOffIcon,
|
||||
waveIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './PlayerBar.module.css';
|
||||
|
||||
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
|
||||
export function PlayerBar() {
|
||||
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
||||
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const track = st.track;
|
||||
|
||||
if (!track) {
|
||||
return (
|
||||
<footer className={s.bar} aria-label={t('player.label')}>
|
||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||
<span className={s.empty}>
|
||||
<span className={s.placeholder}>
|
||||
<Icon src={musicIcon} size={22} />
|
||||
@@ -43,12 +50,22 @@ export function PlayerBar() {
|
||||
);
|
||||
}
|
||||
|
||||
const react = (r: 'like' | 'dislike') =>
|
||||
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
const ctx = st.context;
|
||||
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
||||
const source = !ctx
|
||||
? null
|
||||
: ctx.type === 'wave'
|
||||
? ctx.label
|
||||
? t('player.source_wave_seed', { label: ctx.label })
|
||||
: t('player.source_wave')
|
||||
: ctx.type === 'collection'
|
||||
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
||||
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
|
||||
|
||||
return (
|
||||
<footer className={s.bar} aria-label={t('player.label')}>
|
||||
<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.meta}>
|
||||
@@ -56,37 +73,31 @@ export function PlayerBar() {
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
</span>
|
||||
{source &&
|
||||
(ctx?.type === 'album' && ctx.id ? (
|
||||
<Link to="/album/$id" params={{ id: ctx.id }} className={s.source}>
|
||||
<Icon src={albumIcon} size={14} />
|
||||
{source}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cx(s.source, ctx?.type === 'wave' && s.sourceWave)}>
|
||||
<Icon src={ctx?.type === 'wave' ? waveIcon : albumIcon} size={14} />
|
||||
{source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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={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.center}>
|
||||
<div className={s.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
aria-label={t('player.shuffle')}
|
||||
aria-pressed={st.shuffle}
|
||||
onClick={() => player.toggleShuffle()}
|
||||
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={shuffleIcon} size={20} />
|
||||
<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={26} />
|
||||
@@ -105,12 +116,12 @@ export function PlayerBar() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
onClick={() => player.cycleRepeat()}
|
||||
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={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.progress}>
|
||||
@@ -137,6 +148,33 @@ export function PlayerBar() {
|
||||
</div>
|
||||
|
||||
<div className={s.right}>
|
||||
<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={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
onClick={() => player.cycleRepeat()}
|
||||
>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, queueOpen && s.accent)}
|
||||
aria-label={t('player.queue')}
|
||||
aria-expanded={queueOpen}
|
||||
onClick={() => setQueueOpen((v) => !v)}
|
||||
>
|
||||
<Icon src={queueIcon} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={s.icon}
|
||||
@@ -157,6 +195,44 @@ export function PlayerBar() {
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const upcoming = st.queue.slice(st.index, st.index + 40);
|
||||
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||
return createPortal(
|
||||
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<div className={s.drawerHead}>
|
||||
<span>{t('player.queue')}</span>
|
||||
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
<Icon src={closeIcon} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.drawerList}>
|
||||
{upcoming.map((tr, k) => (
|
||||
<button
|
||||
key={`${tr.id}-${k}`}
|
||||
type="button"
|
||||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||||
onClick={() => player.jump(st.index + k)}
|
||||
>
|
||||
<Cover album={tr.album} size={40} />
|
||||
<span className={s.meta}>
|
||||
<span className={s.title}>{tr.title}</span>
|
||||
<span className={s.artists}>{tr.artists.map((a) => a.name).join(', ')}</span>
|
||||
</span>
|
||||
<span className={s.time}>{formatDuration(tr.duration_ms)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user