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' }) { ); diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index e393d41..bd5705d 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 9bf7b21..c086103 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -122,7 +122,8 @@ "source_wave_seed": "Волна по исполнителю · {{label}}", "queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания", "now_playing": "Сейчас играет", - "expand": "Открыть плеер" + "expand": "Открыть плеер", + "more": "Ещё" }, "settings": { "title": "Настройки",