feat(web): player bar, playable track lists and Collection (M3)
- player bar with cover, like and dislike, previous / play / next, shuffle, repeat, seek bar and volume; PlayerProvider lives with the signed-in shell and restores the last track and position paused - clicking a row (or Enter) plays the list from that track with its context (album, search, history, collection); the current track is highlighted and rows have a like button; a reaction refreshes lists - Collection: history grouped by day with clearing, likes with "play all" and dislikes, all with paging; sidebar section - events and playback state are flushed when the page is hidden; player timers survive StrictMode remounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec3c2810c8
commit
976ffb574b
15 files changed
+803
-49
No files matched your search
@@ -129,32 +129,6 @@
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.player {
|
||||
grid-area: player;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.playerEmpty {
|
||||
color: var(--text3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.shell {
|
||||
grid-template-columns: 72px 1fr;
|
||||
@@ -205,7 +179,4 @@
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.player {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,32 @@ import {
|
||||
libraryFillIcon,
|
||||
libraryIcon,
|
||||
logoutIcon,
|
||||
musicIcon,
|
||||
historyFillIcon,
|
||||
historyIcon,
|
||||
likeFillIcon,
|
||||
likeIcon,
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
storageFillIcon,
|
||||
storageIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider } from '../player/context';
|
||||
import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
|
||||
type NavPath = '/' | '/admin/users' | '/admin/audit' | '/admin/sources' | '/admin/library';
|
||||
type NavPath =
|
||||
| '/'
|
||||
| '/admin/users'
|
||||
| '/admin/audit'
|
||||
| '/admin/sources'
|
||||
| '/admin/library'
|
||||
| '/collection/history'
|
||||
| '/collection/likes'
|
||||
| '/collection/dislikes';
|
||||
|
||||
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
||||
return (
|
||||
@@ -39,7 +54,11 @@ function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string;
|
||||
|
||||
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
||||
export function AppShell() {
|
||||
return useAuthState().status === 'signed_in' ? <Shell /> : null;
|
||||
return useAuthState().status === 'signed_in' ? (
|
||||
<PlayerProvider>
|
||||
<Shell />
|
||||
</PlayerProvider>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
@@ -84,6 +103,10 @@ function Shell() {
|
||||
|
||||
<nav className={s.side} aria-label={t('nav.main')}>
|
||||
<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} />
|
||||
{staff && (
|
||||
<>
|
||||
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||
@@ -103,12 +126,7 @@ function Shell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className={s.player} aria-label={t('player.empty')}>
|
||||
<span className={s.cover}>
|
||||
<Icon src={musicIcon} size={22} />
|
||||
</span>
|
||||
<span className={s.playerEmpty}>{t('player.empty')}</span>
|
||||
</footer>
|
||||
<PlayerBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
.bar {
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--text3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artists {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.play {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #05070d;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
|
||||
.play:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.range {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent2) var(--pct, 0%), var(--surface3) var(--pct, 0%));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.range:hover::-webkit-slider-thumb,
|
||||
.range:focus-visible::-webkit-slider-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.range::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.volume {
|
||||
flex: 0 1 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress,
|
||||
.right,
|
||||
.controls > :first-child,
|
||||
.controls > :last-child,
|
||||
.now > button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
likeFillIcon,
|
||||
likeIcon,
|
||||
musicIcon,
|
||||
nextIcon,
|
||||
pauseIcon,
|
||||
playIcon,
|
||||
prevIcon,
|
||||
repeatIcon,
|
||||
repeatOneIcon,
|
||||
shuffleIcon,
|
||||
volumeIcon,
|
||||
volumeOffIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './PlayerBar.module.css';
|
||||
|
||||
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
|
||||
export function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
|
||||
if (!track) {
|
||||
return (
|
||||
<footer className={s.bar} aria-label={t('player.label')}>
|
||||
<span className={s.empty}>
|
||||
<span className={s.placeholder}>
|
||||
<Icon src={musicIcon} size={22} />
|
||||
</span>
|
||||
{t('player.empty')}
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
const react = (r: 'like' | 'dislike') =>
|
||||
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
|
||||
return (
|
||||
<footer className={s.bar} aria-label={t('player.label')}>
|
||||
<div className={s.now}>
|
||||
<Cover album={track.album} size={52} />
|
||||
<div className={s.meta}>
|
||||
<span className={s.title}>{track.title}</span>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
</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()}
|
||||
>
|
||||
<Icon src={shuffleIcon} size={20} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={s.play}
|
||||
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
aria-busy={st.loading || undefined}
|
||||
onClick={() => player.toggle()}
|
||||
>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={26} />
|
||||
</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>
|
||||
</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')}
|
||||
aria-valuetext={formatDuration(st.positionMs)}
|
||||
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>
|
||||
{st.error && (
|
||||
<span className={s.error} role="status">
|
||||
{t(st.error as 'player.error')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.right}>
|
||||
<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={20} />
|
||||
</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>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user