Fix track page hero layout and add full-screen player
ci / ts / ts (packages + web) (push) Successful in 5m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-11-04 00:17:55 +03:00
1 parent 5d0decbb2a
commit f7ffa5d742
8 files changed
+429 -12

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
<img src={coverSrc(tr.album.cover_url, 300)} alt="" /> <img src={coverSrc(tr.album.cover_url, 300)} alt="" />
</span> </span>
)} )}
<Cover album={tr.album} size={280} className={c.heroCover} /> <Cover album={tr.album} size="100%" className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span> <span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1> <h1 className={c.heroTitle}>{tr.title}</h1>
+11 -1
View File
@@ -99,6 +99,11 @@
min-width: 0; min-width: 0;
} }
.listen .heroCover {
max-width: 280px;
aspect-ratio: 1;
}
.listen { .listen {
display: grid; display: grid;
gap: 28px; gap: 28px;
@@ -118,7 +123,12 @@
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
margin: 0; margin: 0;
padding: 0; padding: 20px;
border-radius: 20px;
}
.listen .heroCover {
max-width: none;
} }
.listen .heroTitle { .listen .heroTitle {
+1
View File
@@ -75,3 +75,4 @@ export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg'; export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg'; export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg'; export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
export { default as chevronDownIcon } from '@material-symbols/svg-400/rounded/keyboard_arrow_down.svg';
+241
View File
@@ -0,0 +1,241 @@
.root {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
overflow-y: auto;
background: var(--bg, #05070d);
animation: pipuIn 0.18s ease-out;
}
.bg {
position: fixed;
inset: -20%;
opacity: 0.35;
filter: blur(80px) saturate(1.2);
pointer-events: none;
}
.bg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.close {
position: fixed;
top: 20px;
right: 24px;
z-index: 2;
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
backdrop-filter: blur(12px);
}
.close:hover {
background: rgba(255, 255, 255, 0.22);
}
.stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20px;
width: min(444px, calc(100vw - 32px), calc(100dvh - 240px));
min-width: 260px;
padding: 72px 0 32px;
}
.card {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 0;
}
.shade {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.round {
position: absolute;
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(20, 20, 20, 0.82);
color: #fff;
transition: background 0.12s;
}
.round:hover:not(:disabled) {
background: rgba(50, 50, 50, 0.9);
}
.controls .round {
position: static;
}
.accent {
color: #ffe600;
}
.topRight {
top: 16px;
right: 16px;
}
.bottomRight {
right: 16px;
bottom: 16px;
}
.moreWrap {
position: absolute;
left: 16px;
bottom: 16px;
}
.moreWrap .round {
position: static;
}
.menu {
position: absolute;
left: 0;
bottom: 56px;
display: flex;
flex-direction: column;
min-width: 200px;
padding: 6px;
border-radius: 12px;
background: rgba(20, 20, 20, 0.96);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.menuItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: #fff;
text-align: left;
}
.menuItem:hover {
background: rgba(255, 255, 255, 0.1);
}
.controls {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
pointer-events: none;
}
.controls > * {
pointer-events: auto;
}
.play {
display: grid;
place-items: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: #ffe600;
color: #111;
transition: transform 0.12s;
}
.play:hover:not(:disabled) {
transform: scale(1.06);
}
.play[aria-busy='true'] {
opacity: 0.8;
}
.meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
min-width: 0;
}
.title {
font-size: 20px;
font-weight: 700;
color: var(--text, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.artists {
color: var(--text2);
font-size: 16px;
}
.progress {
display: flex;
flex-direction: column;
gap: 6px;
}
.range {
width: 100%;
height: 4px;
appearance: none;
border-radius: 2px;
background: linear-gradient(90deg, #fff var(--pct, 0%), rgba(255, 255, 255, 0.25) var(--pct, 0%));
cursor: pointer;
}
.range::-webkit-slider-thumb {
appearance: none;
width: 0;
height: 0;
}
.range::-moz-range-thumb {
width: 0;
height: 0;
border: 0;
}
.times {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text2);
font-variant-numeric: tabular-nums;
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import {
chevronDownIcon,
likeFillIcon,
likeIcon,
moreIcon,
nextIcon,
pauseIcon,
playIcon,
prevIcon,
queueIcon,
repeatIcon,
repeatOneIcon,
shareIcon,
} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './FullPlayer.module.css';
/** Полноэкранный плеер: обложка с кнопками поверх неё, название и прогресс. Открывается кликом по обложке в плеере. */
export function FullPlayer({ onClose, onQueue, share }: { onClose: () => void; onQueue: () => void; share: () => void }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const [menu, setMenu] = useState(false);
const locked = st.listening?.locked === true;
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (menu) setMenu(false);
else 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, menu]);
if (!track) return null;
const duration = st.durationMs || track.duration_ms;
const liked = track.reaction === 'like';
return createPortal(
<div className={s.root} role="dialog" aria-modal="true" aria-label={t('player.now_playing')}>
{track.album.cover_url && (
<span className={s.bg} aria-hidden>
<img src={coverSrc(track.album.cover_url, 300)} alt="" />
</span>
)}
<button type="button" className={s.close} aria-label={t('form.close')} onClick={onClose}>
<Icon src={chevronDownIcon} size={26} />
</button>
<div className={s.stage}>
<div className={s.card}>
<Cover album={track.album} size="100%" className={s.cover} />
<div className={s.shade} aria-hidden />
<button type="button" className={cx(s.round, s.topRight)} aria-label={t('player.queue')} onClick={onQueue}>
<Icon src={queueIcon} size={22} />
</button>
<div className={s.controls}>
<button type="button" className={s.round} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={22} />
</button>
<button
type="button"
className={s.play}
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
aria-busy={st.loading || undefined}
disabled={locked}
onClick={() => player.toggle()}
>
<Icon src={st.playing ? pauseIcon : playIcon} size={30} />
</button>
<button type="button" className={s.round} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={22} />
</button>
<button
type="button"
className={cx(s.round, st.repeat !== 'off' && s.accent)}
aria-label={t(`player.repeat_${st.repeat}`)}
disabled={locked}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
</div>
<div className={s.moreWrap}>
<button type="button" className={s.round} aria-label={t('player.more')} aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
<Icon src={moreIcon} size={22} />
</button>
{menu && (
<div className={s.menu}>
<AddToPlaylist trackIds={[track.id]} className={s.menuItem} />
<button type="button" className={s.menuItem} onClick={() => { setMenu(false); share(); }}>
<Icon src={shareIcon} size={18} />
{t('player.share')}
</button>
</div>
)}
</div>
<button
type="button"
className={cx(s.round, s.bottomRight, liked && s.accent)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(track, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={24} />
</button>
</div>
<div className={s.meta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.title} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} hideUrlPreview />
</span>
</div>
<div className={s.progress}>
<input
type="range"
className={s.range}
min={0}
max={duration}
step={1000}
value={Math.min(st.positionMs, duration)}
aria-label={t('player.progress')}
aria-valuetext={formatDuration(st.positionMs)}
disabled={locked}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / duration) * 100}%` } as CSSProperties}
/>
<span className={s.times}>
<span>{formatDuration(st.positionMs)}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
</div>
</div>,
document.body,
);
}
+20 -8
View File
@@ -32,6 +32,7 @@ import {
waveIcon, waveIcon,
} from '../shared/ui/icons'; } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui'; import { cx, Icon } from '../shared/ui/ui';
import { FullPlayer } from './FullPlayer';
import s from './PlayerBar.module.css'; import s from './PlayerBar.module.css';
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */ /** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
@@ -43,6 +44,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const locked = st.listening?.locked === true; const locked = st.listening?.locked === true;
const narrow = useNarrow(); const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [fullOpen, setFullOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const queueButton = useRef<HTMLButtonElement>(null); const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId(); const queueId = useId();
@@ -86,13 +88,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}> <footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div <div
className={s.now} className={s.now}
onClick={ onClick={(e) => {
narrow if ((e.target as HTMLElement).closest('a, button')) return;
? (e) => { if (narrow) setSheetOpen(true);
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true); else setFullOpen(true);
} }}
: undefined
}
> >
{narrow ? ( {narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}> <button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
@@ -100,7 +100,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>} {st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button> </button>
) : ( ) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} /> <button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setFullOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
</button>
)} )}
<div className={s.meta}> <div className={s.meta}>
<span className={s.title} title={track.title}>{track.title}</span> <span className={s.title} title={track.title}>{track.title}</span>
@@ -242,6 +244,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
share={share} share={share}
/> />
)} )}
{fullOpen && !narrow && (
<FullPlayer
onClose={() => setFullOpen(false)}
onQueue={() => {
setFullOpen(false);
setQueueOpen(true);
}}
share={share}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />} {queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</footer> </footer>
); );
+2 -1
View File
@@ -122,7 +122,8 @@
"source_wave_seed": "Artist wave · {{label}}", "source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen", "queue_wave": "The wave never ends: next tracks are picked as you listen",
"now_playing": "Now playing", "now_playing": "Now playing",
"expand": "Open player" "expand": "Open player",
"more": "More"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+2 -1
View File
@@ -122,7 +122,8 @@
"source_wave_seed": "Волна по исполнителю · {{label}}", "source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания", "queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
"now_playing": "Сейчас играет", "now_playing": "Сейчас играет",
"expand": "Открыть плеер" "expand": "Открыть плеер",
"more": "Ещё"
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",