Fix track page hero layout and add full-screen player
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d0decbb2a
commit
f7ffa5d742
8 files changed
+429
-12
No files matched your search
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
|
||||
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
<Cover album={tr.album} size={280} className={c.heroCover} />
|
||||
<Cover album={tr.album} size="100%" className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||
|
||||
@@ -99,6 +99,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listen .heroCover {
|
||||
max-width: 280px;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.listen {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
@@ -118,7 +123,12 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.listen .heroCover {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.listen .heroTitle {
|
||||
|
||||
@@ -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 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 chevronDownIcon } from '@material-symbols/svg-400/rounded/keyboard_arrow_down.svg';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
waveIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import { FullPlayer } from './FullPlayer';
|
||||
import s from './PlayerBar.module.css';
|
||||
|
||||
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
||||
@@ -43,6 +44,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const locked = st.listening?.locked === true;
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [fullOpen, setFullOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const queueButton = useRef<HTMLButtonElement>(null);
|
||||
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')}>
|
||||
<div
|
||||
className={s.now}
|
||||
onClick={
|
||||
narrow
|
||||
? (e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
if (narrow) setSheetOpen(true);
|
||||
else setFullOpen(true);
|
||||
}}
|
||||
>
|
||||
{narrow ? (
|
||||
<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>}
|
||||
</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}>
|
||||
<span className={s.title} title={track.title}>{track.title}</span>
|
||||
@@ -242,6 +244,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
share={share}
|
||||
/>
|
||||
)}
|
||||
{fullOpen && !narrow && (
|
||||
<FullPlayer
|
||||
onClose={() => setFullOpen(false)}
|
||||
onQueue={() => {
|
||||
setFullOpen(false);
|
||||
setQueueOpen(true);
|
||||
}}
|
||||
share={share}
|
||||
/>
|
||||
)}
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
"source_wave_seed": "Artist wave · {{label}}",
|
||||
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||
"now_playing": "Now playing",
|
||||
"expand": "Open player"
|
||||
"expand": "Open player",
|
||||
"more": "More"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
||||
"now_playing": "Сейчас играет",
|
||||
"expand": "Открыть плеер"
|
||||
"expand": "Открыть плеер",
|
||||
"more": "Ещё"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
|
||||
Reference in new issue
Block a user