feat: bg presets in settings, shuffle playlists, remove theme from ExtraTab
- Add 4 live background modes: Орбы, Волны, Частицы, Аврора + Нет - Settings page shows visual preview cards for each bg mode (persisted) - Add shuffle button (⇄) to PlaylistCard and FavoritesCard - Remove accent color picker from ExtraTab (settings page only) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,18 @@ function PlaylistCard({
|
|||||||
setTimeout(() => setLaunched(false), 2500)
|
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 (
|
return (
|
||||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.11] transition-all duration-200">
|
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.11] transition-all duration-200">
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
@@ -229,6 +241,15 @@ function PlaylistCard({
|
|||||||
▶ Play
|
▶ Play
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{trackCount > 1 && (
|
||||||
|
<button onClick={playShuffled} title="Перемешать"
|
||||||
|
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all cursor-pointer">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/>
|
||||||
|
<polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onEdit}
|
<button onClick={onEdit}
|
||||||
className="text-[11px] px-2.5 py-1.5 border border-white/[0.07] rounded-lg bg-transparent text-muted hover:text-app-text hover:border-white/20 transition-all cursor-pointer font-display font-semibold">
|
className="text-[11px] px-2.5 py-1.5 border border-white/[0.07] rounded-lg bg-transparent text-muted hover:text-app-text hover:border-white/20 transition-all cursor-pointer font-display font-semibold">
|
||||||
Изменить
|
Изменить
|
||||||
@@ -370,6 +391,18 @@ function FavoritesCard() {
|
|||||||
setTimeout(() => setLaunched(false), 2500)
|
setTimeout(() => setLaunched(false), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleShuffle = () => {
|
||||||
|
if (!favorites.length) return
|
||||||
|
const titles = [...favorites]
|
||||||
|
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 (
|
return (
|
||||||
<div className="bg-surface border rounded-app overflow-hidden mb-2.5" style={{ borderColor: 'rgba(var(--accent-rgb),0.2)' }}>
|
<div className="bg-surface border rounded-app overflow-hidden mb-2.5" style={{ borderColor: 'rgba(var(--accent-rgb),0.2)' }}>
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
@@ -408,6 +441,15 @@ function FavoritesCard() {
|
|||||||
>
|
>
|
||||||
{launched ? '▶ Играет' : '▶ Play'}
|
{launched ? '▶ Играет' : '▶ Play'}
|
||||||
</button>
|
</button>
|
||||||
|
{favorites.length > 1 && (
|
||||||
|
<button onClick={handleShuffle} title="Перемешать"
|
||||||
|
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all cursor-pointer">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/>
|
||||||
|
<polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded && favorites.length > 0 && (
|
{expanded && favorites.length > 0 && (
|
||||||
|
|||||||
@@ -4,11 +4,115 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
||||||
|
import { useBgStore, BG_PRESETS, type BgMode } from '@/store/bgStore'
|
||||||
import Header from '@/components/Header'
|
import Header from '@/components/Header'
|
||||||
|
|
||||||
|
function OrbsPreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="s-o1" cx="22%" cy="18%" r="55%">
|
||||||
|
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/>
|
||||||
|
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="s-o2" cx="82%" cy="85%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.4"/>
|
||||||
|
<stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="s-o3" cx="55%" cy="55%" r="35%">
|
||||||
|
<stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.22"/>
|
||||||
|
<stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<ellipse cx="26" cy="12" rx="58" ry="58" fill="url(#s-o1)"/>
|
||||||
|
<ellipse cx="98" cy="58" rx="48" ry="48" fill="url(#s-o2)"/>
|
||||||
|
<ellipse cx="66" cy="37" rx="32" ry="32" fill="url(#s-o3)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WavesPreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<path d="M0 46 Q15 40 30 46 Q45 52 60 46 Q75 40 90 46 Q105 52 120 46 L120 68 L0 68Z"
|
||||||
|
fill="var(--accent)" opacity="0.14"/>
|
||||||
|
<path d="M0 54 Q15 48 30 54 Q45 60 60 54 Q75 48 90 54 Q105 60 120 54 L120 68 L0 68Z"
|
||||||
|
fill="var(--accent)" opacity="0.2"/>
|
||||||
|
<path d="M0 40 Q20 34 40 40 Q60 46 80 40 Q100 34 120 40 L120 68 L0 68Z"
|
||||||
|
fill="rgb(255,60,172)" opacity="0.09"/>
|
||||||
|
<path d="M0 60 Q20 56 40 60 Q60 64 80 60 Q100 56 120 60 L120 68 L0 68Z"
|
||||||
|
fill="rgb(140,100,255)" opacity="0.12"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
{lines.map(([a, b], i) => (
|
||||||
|
<line key={i} x1={dots[a][0]} y1={dots[a][1]} x2={dots[b][0]} y2={dots[b][1]}
|
||||||
|
stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
|
||||||
|
))}
|
||||||
|
{dots.map(([x, y], i) => (
|
||||||
|
<circle key={i} cx={x} cy={y} r={i % 3 === 0 ? 2 : 1.3} fill="var(--accent)" opacity={i % 2 === 0 ? 0.65 : 0.45}/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuroraPreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="s-a1" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.38"/>
|
||||||
|
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="s-a2" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.3"/>
|
||||||
|
<stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="s-a3" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.25"/>
|
||||||
|
<stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<ellipse cx="18" cy="17" rx="66" ry="20" fill="url(#s-a1)"/>
|
||||||
|
<ellipse cx="60" cy="10" rx="80" ry="16" fill="url(#s-a2)"/>
|
||||||
|
<ellipse cx="96" cy="24" rx="55" ry="18" fill="url(#s-a3)"/>
|
||||||
|
<ellipse cx="42" cy="38" rx="70" ry="14" fill="url(#s-a1)" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NonePreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<line x1="50" y1="34" x2="70" y2="34" stroke="#333" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<line x1="60" y1="24" x2="60" y2="44" stroke="#333" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
|
||||||
|
orbs: <OrbsPreview />,
|
||||||
|
waves: <WavesPreview />,
|
||||||
|
particles: <ParticlesPreview />,
|
||||||
|
aurora: <AuroraPreview />,
|
||||||
|
none: <NonePreview />,
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { accentIdx, setAccent } = useThemeStore()
|
const { accentIdx, setAccent } = useThemeStore()
|
||||||
|
const { bgMode, setBg } = useBgStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,29 +133,55 @@ export default function SettingsPage() {
|
|||||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
|
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
|
||||||
Внешний вид
|
Внешний вид
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] text-muted mb-3">Акцентный цвет</p>
|
|
||||||
<div className="flex flex-wrap gap-2.5">
|
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-5">
|
||||||
{ACCENT_PRESETS.map((preset, i) => (
|
{ACCENT_PRESETS.map((preset, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setAccent(i)}
|
onClick={() => setAccent(i)}
|
||||||
className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-[10px] text-[13px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||||
style={accentIdx === i
|
style={accentIdx === i
|
||||||
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
|
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
|
||||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-3.5 h-3.5 rounded-full shrink-0"
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
style={{
|
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}
|
||||||
background: preset.accent,
|
|
||||||
boxShadow: accentIdx === i ? `0 0 8px ${preset.accent}90` : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{preset.name}
|
{preset.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{BG_PRESETS.map((preset) => {
|
||||||
|
const active = bgMode === preset.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => setBg(preset.id)}
|
||||||
|
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
||||||
|
style={active
|
||||||
|
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
||||||
|
: { borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="w-full aspect-video overflow-hidden">
|
||||||
|
{BG_PREVIEWS[preset.id]}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
|
||||||
|
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||||
|
{preset.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { audioState } from '@/lib/audioState'
|
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() {
|
export default function AudioBackground() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const { bgMode } = useBgStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (bgMode === 'none') return
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
@@ -24,81 +30,204 @@ export default function AudioBackground() {
|
|||||||
resize()
|
resize()
|
||||||
window.addEventListener('resize', resize)
|
window.addEventListener('resize', resize)
|
||||||
|
|
||||||
const draw = () => {
|
const tick = () => {
|
||||||
rafId = requestAnimationFrame(draw)
|
let rB = 0, rM = 0
|
||||||
const W = canvas.width
|
|
||||||
const H = canvas.height
|
|
||||||
|
|
||||||
let rawBass = 0
|
|
||||||
let rawMid = 0
|
|
||||||
|
|
||||||
if (audioState.analyser && audioState.isPlaying) {
|
if (audioState.analyser && audioState.isPlaying) {
|
||||||
const binCount = audioState.analyser.frequencyBinCount
|
const n = audioState.analyser.frequencyBinCount
|
||||||
if (!dataBuf || dataBuf.length !== binCount) dataBuf = new Uint8Array(new ArrayBuffer(binCount))
|
if (!dataBuf || dataBuf.length !== n) dataBuf = new Uint8Array(new ArrayBuffer(n))
|
||||||
audioState.analyser.getByteFrequencyData(dataBuf)
|
audioState.analyser.getByteFrequencyData(dataBuf)
|
||||||
const bassEnd = Math.max(1, Math.ceil(binCount * 0.1))
|
const bEnd = Math.max(1, Math.ceil(n * 0.1))
|
||||||
for (let i = 0; i < bassEnd; i++) rawBass = Math.max(rawBass, dataBuf[i] / 255)
|
for (let i = 0; i < bEnd; i++) rB = Math.max(rB, dataBuf[i] / 255)
|
||||||
const midStart = bassEnd
|
const mEnd = Math.ceil(n * 0.4)
|
||||||
const midEnd = Math.ceil(binCount * 0.4)
|
let mSum = 0
|
||||||
let midSum = 0
|
for (let i = bEnd; i < mEnd; i++) mSum += dataBuf[i] / 255
|
||||||
for (let i = midStart; i < midEnd; i++) midSum += dataBuf[i] / 255
|
rM = mSum / Math.max(1, mEnd - bEnd)
|
||||||
rawMid = midSum / Math.max(1, midEnd - midStart)
|
|
||||||
}
|
}
|
||||||
|
smoothBass += (rB - smoothBass) * 0.1
|
||||||
|
smoothMid += (rM - smoothMid) * 0.07
|
||||||
|
}
|
||||||
|
|
||||||
smoothBass += (rawBass - smoothBass) * 0.1
|
const acRgb = () =>
|
||||||
smoothMid += (rawMid - smoothMid) * 0.07
|
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 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.clearRect(0, 0, W, H)
|
||||||
ctx.fillStyle = '#0a0a0f'
|
ctx.fillStyle = '#0a0a0f'
|
||||||
ctx.fillRect(0, 0, W, H)
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
const accentRgb = document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0'
|
const r1 = base * (0.75 + smoothBass * 0.55 + b)
|
||||||
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 a1 = 0.055 + smoothBass * 0.07
|
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)
|
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, `rgba(${ar},${a1})`); g1.addColorStop(0.45, `rgba(${ar},${a1 * 0.28})`); g1.addColorStop(1, `rgba(${ar},0)`)
|
||||||
g1.addColorStop(0.45, `rgba(${accentRgb},${a1 * 0.28})`)
|
ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
|
||||||
g1.addColorStop(1, `rgba(${accentRgb},0)`)
|
|
||||||
ctx.fillStyle = g1
|
|
||||||
ctx.fillRect(0, 0, W, H)
|
|
||||||
|
|
||||||
// Pink orb — mid driven, bottom-right
|
const r2 = base * (0.65 + smoothMid * 0.45 - b * 0.6)
|
||||||
const r2 = base * (0.65 + smoothMid * 0.45 - breathe * 0.6)
|
|
||||||
const a2 = 0.045 + smoothMid * 0.055
|
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)
|
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, `rgba(255,60,172,${a2})`); g2.addColorStop(0.45, `rgba(255,60,172,${a2 * 0.28})`); g2.addColorStop(1, 'rgba(255,60,172,0)')
|
||||||
g2.addColorStop(0.45, `rgba(255,60,172,${a2 * 0.28})`)
|
ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
|
||||||
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 c = smoothBass * 0.55 + smoothMid * 0.45
|
||||||
const combined = smoothBass * 0.55 + smoothMid * 0.45
|
if (c > 0.005) {
|
||||||
if (combined > 0.005) {
|
const r3 = base * (0.38 + c * 0.32)
|
||||||
const r3 = base * (0.38 + combined * 0.32)
|
|
||||||
const a3 = combined * 0.035
|
|
||||||
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.52, 0, W * 0.5, H * 0.52, r3)
|
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(0, `rgba(140,100,255,${c * 0.035})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
|
||||||
g3.addColorStop(1, 'rgba(140,100,255,0)')
|
ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
|
||||||
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 () => {
|
return () => {
|
||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
window.removeEventListener('resize', resize)
|
window.removeEventListener('resize', resize)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [bgMode])
|
||||||
|
|
||||||
|
if (bgMode === 'none') return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
|
||||||
import { COLORS, initials } from '@/lib/colors'
|
import { COLORS, initials } from '@/lib/colors'
|
||||||
import type { Color } from '@/types'
|
import type { Color } from '@/types'
|
||||||
|
|
||||||
export default function ExtraTab() {
|
export default function ExtraTab() {
|
||||||
const { queue, curIdx, addFairToQueue } = usePartyStore()
|
const { queue, curIdx, addFairToQueue } = usePartyStore()
|
||||||
const { accentIdx, setAccent } = useThemeStore()
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [tracks, setTracks] = useState('')
|
const [tracks, setTracks] = useState('')
|
||||||
const [selectedColor, setSelectedColor] = useState<Color>(COLORS[1])
|
const [selectedColor, setSelectedColor] = useState<Color>(COLORS[1])
|
||||||
@@ -181,32 +179,6 @@ export default function ExtraTab() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Theme color picker */}
|
|
||||||
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mt-4">
|
|
||||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
|
|
||||||
Цветовая тема
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2.5">
|
|
||||||
{ACCENT_PRESETS.map((preset, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAccent(i)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
|
||||||
style={accentIdx === i
|
|
||||||
? { background: `rgba(${preset.rgb},0.15)`, color: preset.accent, borderColor: `${preset.accent}60` }
|
|
||||||
: { background: 'transparent', color: '#555', borderColor: 'rgba(255,255,255,0.07)' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 6px ${preset.accent}80` : 'none' }}
|
|
||||||
/>
|
|
||||||
{preset.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
38
apps/web/src/store/bgStore.ts
Normal file
38
apps/web/src/store/bgStore.ts
Normal file
@@ -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<BgStore>((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 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user