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
@@ -0,0 +1,108 @@
|
||||
.ambient {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: var(--amb-op);
|
||||
animation: pipuHue 30s linear infinite;
|
||||
}
|
||||
|
||||
.ambient span {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.a {
|
||||
left: -22vmax;
|
||||
top: -28vmax;
|
||||
width: 70vmax;
|
||||
height: 70vmax;
|
||||
background: radial-gradient(circle, rgba(47, 128, 255, 0.75) 0%, transparent 62%);
|
||||
animation: pipuDrift1 16s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.b {
|
||||
right: -20vmax;
|
||||
top: 8vh;
|
||||
width: 60vmax;
|
||||
height: 60vmax;
|
||||
background: radial-gradient(circle, rgba(124, 92, 255, 0.6) 0%, transparent 62%);
|
||||
animation: pipuDrift2 21s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.c {
|
||||
left: 22vw;
|
||||
bottom: -32vmax;
|
||||
width: 55vmax;
|
||||
height: 55vmax;
|
||||
background: radial-gradient(circle, var(--amb-c3, rgba(53, 211, 255, 0.6)) 0%, transparent 62%);
|
||||
animation: pipuDrift3 26s ease-in-out infinite alternate;
|
||||
transition: background 1.2s;
|
||||
}
|
||||
|
||||
@keyframes pipuDrift1 {
|
||||
0% {
|
||||
transform: translate(-6%, -4%) scale(1);
|
||||
}
|
||||
35% {
|
||||
transform: translate(45%, 28%) scale(1.2);
|
||||
}
|
||||
70% {
|
||||
transform: translate(15%, 60%) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(60%, -5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pipuDrift2 {
|
||||
0% {
|
||||
transform: translate(4%, 0) scale(1.05);
|
||||
}
|
||||
40% {
|
||||
transform: translate(-50%, 40%) scale(0.9);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-20%, -25%) scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-60%, 15%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pipuDrift3 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.95);
|
||||
}
|
||||
30% {
|
||||
transform: translate(-40%, -45%) scale(1.15);
|
||||
}
|
||||
65% {
|
||||
transform: translate(45%, -25%) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(10%, -55%) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pipuHue {
|
||||
0% {
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
filter: hue-rotate(-35deg);
|
||||
}
|
||||
100% {
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient,
|
||||
.ambient span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import s from './Ambient.module.css';
|
||||
|
||||
/**
|
||||
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
|
||||
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
|
||||
*/
|
||||
export function Ambient({ color }: { color: string }) {
|
||||
return (
|
||||
<div className={s.ambient} aria-hidden>
|
||||
<span className={s.a} />
|
||||
<span className={s.b} />
|
||||
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-rows: 60px 1fr 88px;
|
||||
@@ -9,6 +10,23 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shell > :where(:not([aria-hidden])) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
|
||||
.island {
|
||||
grid-template-rows: 60px 1fr;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'side content';
|
||||
}
|
||||
|
||||
.island .content {
|
||||
padding-bottom: 132px;
|
||||
}
|
||||
|
||||
.top {
|
||||
grid-area: top;
|
||||
display: flex;
|
||||
@@ -118,6 +136,33 @@
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navCount {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.navBadge {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.navItem[data-status='active'] .navIcon {
|
||||
color: var(--accent2);
|
||||
}
|
||||
@@ -140,6 +185,12 @@
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.navLabel,
|
||||
.navCount,
|
||||
.navBadge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navSection {
|
||||
margin: 14px 0 6px;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import { useCollectionCounts } from '../entities/collection';
|
||||
import {
|
||||
auditFillIcon,
|
||||
auditIcon,
|
||||
@@ -20,10 +23,11 @@ import {
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon} from '../shared/ui/icons';
|
||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider } from '../player/context';
|
||||
import { PlayerProvider, usePlayerState } from '../player/context';
|
||||
import { Ambient } from './Ambient';
|
||||
import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
@@ -37,17 +41,34 @@ type NavPath =
|
||||
| '/admin/uploads'
|
||||
| '/uploads/new'
|
||||
| '/uploads/mine'
|
||||
| '/admin/settings'
|
||||
| '/collection/history'
|
||||
| '/collection/likes'
|
||||
| '/collection/dislikes';
|
||||
|
||||
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
||||
function NavLink({
|
||||
to,
|
||||
label,
|
||||
icon,
|
||||
activeIcon,
|
||||
count,
|
||||
badge,
|
||||
}: {
|
||||
to: NavPath;
|
||||
label: string;
|
||||
icon: string;
|
||||
activeIcon: string;
|
||||
count?: number; // счётчик справа (лайки, дизлайки)
|
||||
badge?: number; // яркий бейдж (ждут решения)
|
||||
}) {
|
||||
return (
|
||||
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
||||
{label}
|
||||
<span className={s.navLabel}>{label}</span>
|
||||
{badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
||||
{!badge && count ? <span className={s.navCount}>{count}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
@@ -70,9 +91,13 @@ function Shell() {
|
||||
const navigate = useNavigate();
|
||||
const name = user.display_name ?? user.username;
|
||||
const staff = user.role !== 'user';
|
||||
const island = user.ui_prefs.web_player === 'island';
|
||||
const counts = useCollectionCounts();
|
||||
const pending = usePendingUploads(user.role === 'admin');
|
||||
|
||||
return (
|
||||
<div className={s.shell}>
|
||||
<div className={cx(s.shell, island && s.island)}>
|
||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||
<header className={s.top}>
|
||||
<Link to="/" className={s.logoLink}>
|
||||
<Logo />
|
||||
@@ -107,24 +132,36 @@ function Shell() {
|
||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||
<div className={s.navSection}>{t('nav.collection')}</div>
|
||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
|
||||
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||
<NavLink
|
||||
to="/collection/dislikes"
|
||||
label={t('nav.dislikes')}
|
||||
icon={dislikeIcon}
|
||||
activeIcon={dislikeFillIcon}
|
||||
count={counts.dislikes}
|
||||
/>
|
||||
{staff && (
|
||||
<>
|
||||
<div className={s.navSection}>{t('nav.uploads')}</div>
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/uploads" label={t('nav.upload_queue')} icon={inboxIcon} activeIcon={inboxFillIcon} />
|
||||
)}
|
||||
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
<>
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -134,7 +171,28 @@ function Shell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<PlayerBar />
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||
function ShellAmbient() {
|
||||
const track = usePlayerState().track;
|
||||
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
|
||||
}
|
||||
|
||||
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
|
||||
function usePendingUploads(enabled: boolean) {
|
||||
const session = useSession();
|
||||
const q = useQuery({
|
||||
queryKey: ['admin-uploads-pending'],
|
||||
enabled,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: ['pending_review'], limit: 100 } } }),
|
||||
).items.length,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
return q.data;
|
||||
}
|
||||
@@ -10,6 +10,45 @@
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.bar.island {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 16px;
|
||||
z-index: 5;
|
||||
width: min(1040px, calc(100% - 32px));
|
||||
height: 84px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
a.source {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.source:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sourceWave {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -192,3 +231,61 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 100px;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100% - 32px));
|
||||
max-height: min(560px, calc(100% - 180px));
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
box-shadow: var(--shadow);
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.drawerIsland {
|
||||
bottom: 112px;
|
||||
}
|
||||
|
||||
.drawerHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 12px 10px 18px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawerList {
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.queueRow {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.queueRow:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.queueCurrent {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.queueCurrent .title {
|
||||
color: var(--accent2);
|
||||
}
|
||||
@@ -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