diff --git a/apps/web/src/app/playlists/page.tsx b/apps/web/src/app/playlists/page.tsx index e149524..df52507 100644 --- a/apps/web/src/app/playlists/page.tsx +++ b/apps/web/src/app/playlists/page.tsx @@ -189,6 +189,18 @@ function PlaylistCard({ setTimeout(() => setLaunched(false), 2500) } + const playShuffled = () => { + const titles = [...(pl.tracks?.map(t => t.title) ?? [])] + if (!titles.length) return + for (let i = titles.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [titles[i], titles[j]] = [titles[j], titles[i]] + } + loadPlaylist(titles) + setLaunched(true) + setTimeout(() => setLaunched(false), 2500) + } + return (
@@ -229,6 +241,15 @@ function PlaylistCard({ ▶ Play )} + {trackCount > 1 && ( + + )} + {favorites.length > 1 && ( + + )}
{expanded && favorites.length > 0 && ( diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 8135059..34e3f7e 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -4,11 +4,115 @@ import { useRouter } from 'next/navigation' import { useEffect } from 'react' import { useAuthStore } from '@/store/authStore' import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore' +import { useBgStore, BG_PRESETS, type BgMode } from '@/store/bgStore' import Header from '@/components/Header' +function OrbsPreview() { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} + +function WavesPreview() { + return ( + + + + + + + + ) +} + +function ParticlesPreview() { + const dots: [number, number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]] + const lines: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]] + return ( + + + {lines.map(([a, b], i) => ( + + ))} + {dots.map(([x, y], i) => ( + + ))} + + ) +} + +function AuroraPreview() { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function NonePreview() { + return ( + + + + + + ) +} + +const BG_PREVIEWS: Record = { + orbs: , + waves: , + particles: , + aurora: , + none: , +} + export default function SettingsPage() { const { user } = useAuthStore() const { accentIdx, setAccent } = useThemeStore() + const { bgMode, setBg } = useBgStore() const router = useRouter() useEffect(() => { @@ -29,29 +133,55 @@ export default function SettingsPage() {

Внешний вид

-

Акцентный цвет

-
+ +

Акцентный цвет

+
{ACCENT_PRESETS.map((preset, i) => ( ))}
+ +

Живой фон

+
+ {BG_PRESETS.map((preset) => { + const active = bgMode === preset.id + return ( + + ) + })} +
diff --git a/apps/web/src/components/AudioBackground.tsx b/apps/web/src/components/AudioBackground.tsx index 302e206..d3ce089 100644 --- a/apps/web/src/components/AudioBackground.tsx +++ b/apps/web/src/components/AudioBackground.tsx @@ -1,12 +1,18 @@ -'use client' +'use client' import { useEffect, useRef } from 'react' import { audioState } from '@/lib/audioState' +import { useBgStore } from '@/store/bgStore' + +type Pt = { x: number; y: number; vx: number; vy: number; r: number; a: number } export default function AudioBackground() { const canvasRef = useRef(null) + const { bgMode } = useBgStore() useEffect(() => { + if (bgMode === 'none') return + const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') @@ -24,81 +30,204 @@ export default function AudioBackground() { resize() window.addEventListener('resize', resize) - const draw = () => { - rafId = requestAnimationFrame(draw) - const W = canvas.width - const H = canvas.height - - let rawBass = 0 - let rawMid = 0 - + const tick = () => { + let rB = 0, rM = 0 if (audioState.analyser && audioState.isPlaying) { - const binCount = audioState.analyser.frequencyBinCount - if (!dataBuf || dataBuf.length !== binCount) dataBuf = new Uint8Array(new ArrayBuffer(binCount)) + const n = audioState.analyser.frequencyBinCount + if (!dataBuf || dataBuf.length !== n) dataBuf = new Uint8Array(new ArrayBuffer(n)) audioState.analyser.getByteFrequencyData(dataBuf) - const bassEnd = Math.max(1, Math.ceil(binCount * 0.1)) - for (let i = 0; i < bassEnd; i++) rawBass = Math.max(rawBass, dataBuf[i] / 255) - const midStart = bassEnd - const midEnd = Math.ceil(binCount * 0.4) - let midSum = 0 - for (let i = midStart; i < midEnd; i++) midSum += dataBuf[i] / 255 - rawMid = midSum / Math.max(1, midEnd - midStart) + const bEnd = Math.max(1, Math.ceil(n * 0.1)) + for (let i = 0; i < bEnd; i++) rB = Math.max(rB, dataBuf[i] / 255) + const mEnd = Math.ceil(n * 0.4) + let mSum = 0 + for (let i = bEnd; i < mEnd; i++) mSum += dataBuf[i] / 255 + rM = mSum / Math.max(1, mEnd - bEnd) } + smoothBass += (rB - smoothBass) * 0.1 + smoothMid += (rM - smoothMid) * 0.07 + } - smoothBass += (rawBass - smoothBass) * 0.1 - smoothMid += (rawMid - smoothMid) * 0.07 + const acRgb = () => + document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0' + // ── ORBS ───────────────────────────────────────────────────────────────── + const drawOrbs = () => { + const W = canvas.width, H = canvas.height + const ar = acRgb() const t = Date.now() / 4500 - const breathe = Math.sin(t) * 0.03 + Math.cos(t * 0.7) * 0.015 + const b = Math.sin(t) * 0.03 + Math.cos(t * 0.7) * 0.015 + const diag = Math.hypot(W, H), base = diag * 0.55 ctx.clearRect(0, 0, W, H) ctx.fillStyle = '#0a0a0f' ctx.fillRect(0, 0, W, H) - const accentRgb = document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0' - const diag = Math.hypot(W, H) - const base = diag * 0.55 - - // Accent orb — bass driven, top-left - const r1 = base * (0.75 + smoothBass * 0.55 + breathe) + const r1 = base * (0.75 + smoothBass * 0.55 + b) const a1 = 0.055 + smoothBass * 0.07 const g1 = ctx.createRadialGradient(W * 0.18, H * 0.08, 0, W * 0.18, H * 0.08, r1) - g1.addColorStop(0, `rgba(${accentRgb},${a1})`) - g1.addColorStop(0.45, `rgba(${accentRgb},${a1 * 0.28})`) - g1.addColorStop(1, `rgba(${accentRgb},0)`) - ctx.fillStyle = g1 - ctx.fillRect(0, 0, W, H) + g1.addColorStop(0, `rgba(${ar},${a1})`); g1.addColorStop(0.45, `rgba(${ar},${a1 * 0.28})`); g1.addColorStop(1, `rgba(${ar},0)`) + ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H) - // Pink orb — mid driven, bottom-right - const r2 = base * (0.65 + smoothMid * 0.45 - breathe * 0.6) + const r2 = base * (0.65 + smoothMid * 0.45 - b * 0.6) const a2 = 0.045 + smoothMid * 0.055 const g2 = ctx.createRadialGradient(W * 0.82, H * 0.92, 0, W * 0.82, H * 0.92, r2) - g2.addColorStop(0, `rgba(255,60,172,${a2})`) - g2.addColorStop(0.45, `rgba(255,60,172,${a2 * 0.28})`) - g2.addColorStop(1, 'rgba(255,60,172,0)') - ctx.fillStyle = g2 - ctx.fillRect(0, 0, W, H) + g2.addColorStop(0, `rgba(255,60,172,${a2})`); g2.addColorStop(0.45, `rgba(255,60,172,${a2 * 0.28})`); g2.addColorStop(1, 'rgba(255,60,172,0)') + ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H) - // Purple orb — combined, center, fades in when playing - const combined = smoothBass * 0.55 + smoothMid * 0.45 - if (combined > 0.005) { - const r3 = base * (0.38 + combined * 0.32) - const a3 = combined * 0.035 + const c = smoothBass * 0.55 + smoothMid * 0.45 + if (c > 0.005) { + const r3 = base * (0.38 + c * 0.32) const g3 = ctx.createRadialGradient(W * 0.5, H * 0.52, 0, W * 0.5, H * 0.52, r3) - g3.addColorStop(0, `rgba(140,100,255,${a3})`) - g3.addColorStop(1, 'rgba(140,100,255,0)') - ctx.fillStyle = g3 - ctx.fillRect(0, 0, W, H) + g3.addColorStop(0, `rgba(140,100,255,${c * 0.035})`); g3.addColorStop(1, 'rgba(140,100,255,0)') + ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H) } } - draw() + // ── WAVES ───────────────────────────────────────────────────────────────── + const drawWaves = () => { + const W = canvas.width, H = canvas.height + const ar = acRgb() + const t = Date.now() / 1000 + + ctx.clearRect(0, 0, W, H) + ctx.fillStyle = '#0a0a0f' + ctx.fillRect(0, 0, W, H) + + const layers = [ + { y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${ar},` }, + { y: 0.76, amp: 16 + smoothMid * 45, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' }, + { y: 0.81, amp: 24 + smoothBass * 75, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${ar},` }, + { y: 0.87, amp: 12 + smoothMid * 35, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' }, + { y: 0.93, amp: 30 + smoothBass * 90, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${ar},` }, + ] + + for (const l of layers) { + const baseY = H * l.y + ctx.beginPath() + ctx.moveTo(-2, baseY) + for (let x = 0; x <= W + 3; x += 3) { + ctx.lineTo(x, baseY + + Math.sin(x * l.freq + l.ph) * l.amp + + Math.sin(x * l.freq * 2.4 + l.ph * 1.6) * l.amp * 0.25) + } + ctx.lineTo(W + 2, H + 2); ctx.lineTo(-2, H + 2); ctx.closePath() + const g = ctx.createLinearGradient(0, baseY - l.amp * 1.5, 0, H) + g.addColorStop(0, `${l.c}${l.al})`); g.addColorStop(1, `${l.c}0)`) + ctx.fillStyle = g; ctx.fill() + } + } + + // ── PARTICLES ───────────────────────────────────────────────────────────── + const PTS: Pt[] = Array.from({ length: 55 }, () => ({ + x: Math.random(), y: Math.random(), + vx: (Math.random() - 0.5) * 0.00025, + vy: (Math.random() - 0.5) * 0.00025, + r: Math.random() * 1.5 + 0.6, + a: Math.random() * 0.35 + 0.1, + })) + + const drawParticles = () => { + const W = canvas.width, H = canvas.height + const ar = acRgb() + const spd = 1 + smoothBass * 3 + + ctx.fillStyle = 'rgba(10,10,15,0.2)' + ctx.fillRect(0, 0, W, H) + + for (const p of PTS) { + p.x += p.vx * spd; p.y += p.vy * spd + if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1 + if (p.y < 0) p.y += 1; if (p.y > 1) p.y -= 1 + if (smoothBass > 0.35 && Math.random() < 0.07) { + p.vx += (Math.random() - 0.5) * 0.0006 + p.vy += (Math.random() - 0.5) * 0.0006 + const mv = 0.0012 + p.vx = Math.max(-mv, Math.min(mv, p.vx)) + p.vy = Math.max(-mv, Math.min(mv, p.vy)) + } + ctx.beginPath() + ctx.arc(p.x * W, p.y * H, p.r * (1 + smoothBass * 1.2), 0, Math.PI * 2) + ctx.fillStyle = `rgba(${ar},${p.a * (0.7 + smoothMid * 0.3)})` + ctx.fill() + } + + const maxD = Math.min(W, H) * 0.12 + ctx.lineWidth = 0.5 + for (let i = 0; i < PTS.length; i++) { + for (let j = i + 1; j < PTS.length; j++) { + const dx = (PTS[i].x - PTS[j].x) * W + const dy = (PTS[i].y - PTS[j].y) * H + const d = Math.sqrt(dx * dx + dy * dy) + if (d < maxD) { + ctx.beginPath() + ctx.moveTo(PTS[i].x * W, PTS[i].y * H) + ctx.lineTo(PTS[j].x * W, PTS[j].y * H) + ctx.strokeStyle = `rgba(${ar},${(1 - d / maxD) * 0.07 * (1 + smoothMid)})` + ctx.stroke() + } + } + } + } + + // ── AURORA ──────────────────────────────────────────────────────────────── + const drawAurora = () => { + const W = canvas.width, H = canvas.height + const ar = acRgb() + const t = Date.now() / 3000 + + ctx.clearRect(0, 0, W, H) + ctx.fillStyle = '#0a0a0f' + ctx.fillRect(0, 0, W, H) + + const bands = [ + { cx: 0.15, cy: 0.25, w: 0.55, h: 0.30, phase: t * 0.6, al: 0.08 + smoothBass * 0.06, c: ar }, + { cx: 0.50, cy: 0.15, w: 0.70, h: 0.22, phase: -t * 0.4, al: 0.06 + smoothMid * 0.05, c: '140,100,255' }, + { cx: 0.80, cy: 0.35, w: 0.50, h: 0.28, phase: t * 0.5, al: 0.07 + smoothBass * 0.04, c: '255,60,172' }, + { cx: 0.35, cy: 0.55, w: 0.60, h: 0.20, phase: -t * 0.7, al: 0.05 + smoothMid * 0.04, c: ar }, + ] + + for (const band of bands) { + const cx = band.cx * W + const cy = (band.cy + Math.sin(band.phase) * 0.06) * H + const rw = band.w * W + const rh = band.h * H * (1 + smoothBass * 0.3) + + ctx.save() + ctx.translate(cx, cy) + ctx.scale(1, rh / rw) + + const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rw) + g.addColorStop(0, `rgba(${band.c},${band.al})`) + g.addColorStop(0.35, `rgba(${band.c},${band.al * 0.5})`) + g.addColorStop(1, `rgba(${band.c},0)`) + ctx.fillStyle = g + ctx.beginPath() + ctx.arc(0, 0, rw, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + } + } + + // ── LOOP ────────────────────────────────────────────────────────────────── + const drawFn = { orbs: drawOrbs, waves: drawWaves, particles: drawParticles, aurora: drawAurora }[bgMode] ?? drawOrbs + + // Clear once to avoid bleed from previous mode + ctx.clearRect(0, 0, canvas.width, canvas.height) + + const loop = () => { + tick() + drawFn() + rafId = requestAnimationFrame(loop) + } + loop() return () => { cancelAnimationFrame(rafId) window.removeEventListener('resize', resize) } - }, []) + }, [bgMode]) + + if (bgMode === 'none') return null return ( (COLORS[1]) @@ -181,32 +179,6 @@ export default function ExtraTab() { - {/* Theme color picker */} -
-

- Цветовая тема -

-
- {ACCENT_PRESETS.map((preset, i) => ( - - ))} -
-
) } diff --git a/apps/web/src/store/bgStore.ts b/apps/web/src/store/bgStore.ts new file mode 100644 index 0000000..0e03a8e --- /dev/null +++ b/apps/web/src/store/bgStore.ts @@ -0,0 +1,38 @@ +'use client' + +import { create } from 'zustand' + +export type BgMode = 'orbs' | 'waves' | 'particles' | 'aurora' | 'none' + +export interface BgPreset { + id: BgMode + name: string + desc: string +} + +export const BG_PRESETS: BgPreset[] = [ + { id: 'orbs', name: 'Орбы', desc: 'Мягкие цветовые пятна' }, + { id: 'waves', name: 'Волны', desc: 'Звуковые волны снизу' }, + { id: 'particles', name: 'Частицы', desc: 'Плавающая сеть точек' }, + { id: 'aurora', name: 'Аврора', desc: 'Северное сияние' }, + { id: 'none', name: 'Нет', desc: 'Чистый фон' }, +] + +const KEY = 'pm_bg' + +interface BgStore { + bgMode: BgMode + setBg: (mode: BgMode) => void +} + +export const useBgStore = create((set) => ({ + bgMode: (() => { + if (typeof window === 'undefined') return 'orbs' + const saved = localStorage.getItem(KEY) as BgMode | null + return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs' + })(), + setBg: (mode) => { + if (typeof window !== 'undefined') localStorage.setItem(KEY, mode) + set({ bgMode: mode }) + }, +}))