diff --git a/apps/web/src/pages/TrackPage.tsx b/apps/web/src/pages/TrackPage.tsx
index 1218fd1..ef3bcb2 100644
--- a/apps/web/src/pages/TrackPage.tsx
+++ b/apps/web/src/pages/TrackPage.tsx
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
)}
-
+
{t('player.track_page')}
{tr.title}
diff --git a/apps/web/src/pages/catalog.module.css b/apps/web/src/pages/catalog.module.css
index cdb5f98..b837915 100644
--- a/apps/web/src/pages/catalog.module.css
+++ b/apps/web/src/pages/catalog.module.css
@@ -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 {
diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts
index 8ff8f60..495ae8b 100644
--- a/apps/web/src/shared/ui/icons.ts
+++ b/apps/web/src/shared/ui/icons.ts
@@ -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';
diff --git a/apps/web/src/widgets/FullPlayer.module.css b/apps/web/src/widgets/FullPlayer.module.css
new file mode 100644
index 0000000..3038711
--- /dev/null
+++ b/apps/web/src/widgets/FullPlayer.module.css
@@ -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;
+}
diff --git a/apps/web/src/widgets/FullPlayer.tsx b/apps/web/src/widgets/FullPlayer.tsx
new file mode 100644
index 0000000..c01dac4
--- /dev/null
+++ b/apps/web/src/widgets/FullPlayer.tsx
@@ -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(
+
+ {track.album.cover_url && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {menu && (
+
+
+
+
+ )}
+
+
+
+
+
+ {track.title}
+
+
+
+
+
+
+ player.seek(Number(e.target.value))}
+ style={{ '--pct': `${(st.positionMs / duration) * 100}%` } as CSSProperties}
+ />
+
+ {formatDuration(st.positionMs)}
+ {formatDuration(duration)}
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/apps/web/src/widgets/PlayerBar.tsx b/apps/web/src/widgets/PlayerBar.tsx
index 595e83f..ed43814 100644
--- a/apps/web/src/widgets/PlayerBar.tsx
+++ b/apps/web/src/widgets/PlayerBar.tsx
@@ -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
(null);
const queueId = useId();
@@ -86,13 +88,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {