feat: nginx reverse proxy, Spotify import, overlay system, UI overhaul

- Add nginx as single entry point: /api/* → backend, /* → web
- NEXT_PUBLIC_API_URL="" so all API calls are relative (go through nginx)
- Add Spotify playlist import (Client Credentials OAuth, up to 500 tracks)
- Add Yandex/Spotify tabbed import UI on /playlists
- Add stream overlay system (SSE + polling fallback, 9 styles)
- Reorganize pages into (main) route group
- Add QueuePanel, VersionsPanel, Toaster components
- Add overlay settings tab in /settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:45:53 +03:00
parent 87ba7a0ecf
commit 428548a620
55 changed files with 5934 additions and 2052 deletions

View File

@@ -10,12 +10,12 @@ type Star = { x: number; y: number; r: number; ba: number; ph: number; sp: numbe
type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
export default function AudioBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const { bgMode, raysConfig } = useBgStore()
const raysRef = useRef(raysConfig)
const canvasRef = useRef<HTMLCanvasElement>(null)
const { bgMode, fxConfigs } = useBgStore()
const fxRef = useRef(fxConfigs)
// Keep rays config in sync without restarting the animation loop
useEffect(() => { raysRef.current = raysConfig }, [raysConfig])
// Sync config changes without restarting the animation loop
useEffect(() => { fxRef.current = fxConfigs }, [fxConfigs])
useEffect(() => {
if (bgMode === 'none') return
@@ -28,7 +28,7 @@ export default function AudioBackground() {
let rafId: number
let smoothBass = 0
let smoothMid = 0
let fastBass = 0 // fast tracker for onset/beat detection
let fastBass = 0
let dataBuf: Uint8Array<ArrayBuffer> | null = null
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight }
@@ -54,35 +54,45 @@ export default function AudioBackground() {
const ac = () => document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0'
// ── ORBS — ambient, subtle ────────────────────────────────────────────────
const clear = (W: number, H: number, trail: number) => {
if (trail > 0.005) {
ctx.fillStyle = `rgba(10,10,15,${(1 - trail).toFixed(3)})`
ctx.fillRect(0, 0, W, H)
} else {
ctx.clearRect(0, 0, W, H)
ctx.fillStyle = '#0a0a0f'
ctx.fillRect(0, 0, W, H)
}
}
// ── ORBS ─────────────────────────────────────────────────────────────────
const drawOrbs = () => {
const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 5000
const cfg = fxRef.current.orbs
const t = Date.now() / (5000 / cfg.speed)
const br = Math.sin(t) * 0.04 + Math.cos(t * 0.7) * 0.02
const diag = Math.hypot(W, H), base = diag * 0.62
const bri = cfg.brightness
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
clear(W, H, cfg.trail)
// accent top-left
const r1 = base * (0.78 + smoothBass * 0.45 + br)
const a1 = 0.07 + smoothBass * 0.08
const a1 = (0.07 + smoothBass * 0.08) * bri
const g1 = ctx.createRadialGradient(W * 0.12, H * 0.06, 0, W * 0.12, H * 0.06, r1)
g1.addColorStop(0, `rgba(${a},${a1})`); g1.addColorStop(0.42, `rgba(${a},${a1 * 0.25})`); g1.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
// pink bottom-right
const r2 = base * (0.70 + smoothMid * 0.40 - br * 0.5)
const a2 = 0.06 + smoothMid * 0.07
const a2 = (0.06 + smoothMid * 0.07) * bri
const g2 = ctx.createRadialGradient(W * 0.88, H * 0.94, 0, W * 0.88, H * 0.94, r2)
g2.addColorStop(0, `rgba(255,60,172,${a2})`); g2.addColorStop(0.42, `rgba(255,60,172,${a2 * 0.25})`); g2.addColorStop(1, 'rgba(255,60,172,0)')
ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
// purple center — only shows with audio
const c = smoothBass * 0.5 + smoothMid * 0.5
if (c > 0.008) {
const r3 = base * (0.40 + c * 0.35)
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, r3)
g3.addColorStop(0, `rgba(140,100,255,${0.035 + c * 0.06})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
g3.addColorStop(0, `rgba(140,100,255,${(0.035 + c * 0.06) * bri})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
}
}
@@ -90,14 +100,16 @@ export default function AudioBackground() {
// ── WAVES ─────────────────────────────────────────────────────────────────
const drawWaves = () => {
const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 1000
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
const cfg = fxRef.current.waves
const t = (Date.now() / 1000) * cfg.speed
const amp = cfg.amplitude
clear(W, H, cfg.trail)
const layers = [
{ y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
{ 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(${a},` },
{ 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(${a},` },
{ y: 0.70, amp: (20 + smoothBass * 60) * amp, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
{ y: 0.76, amp: (16 + smoothMid * 45) * amp, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
{ y: 0.81, amp: (24 + smoothBass * 75) * amp, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
{ y: 0.87, amp: (12 + smoothMid * 35) * amp, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
{ y: 0.93, amp: (30 + smoothBass * 90) * amp, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
]
for (const l of layers) {
const baseY = H * l.y
@@ -119,8 +131,9 @@ export default function AudioBackground() {
}))
const drawParticles = () => {
const W = canvas.width, H = canvas.height, a = ac()
ctx.fillStyle = 'rgba(10,10,15,0.2)'; ctx.fillRect(0, 0, W, H)
const spd = 1 + smoothBass * 3
const cfg = fxRef.current.particles
clear(W, H, cfg.trail)
const spd = (1 + smoothBass * 3) * cfg.speed
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
@@ -133,7 +146,7 @@ export default function AudioBackground() {
ctx.beginPath(); ctx.arc(p.x * W, p.y * H, p.r * (1 + smoothBass * 1.2), 0, Math.PI * 2)
ctx.fillStyle = `rgba(${a},${p.a * (0.7 + smoothMid * 0.3)})`; ctx.fill()
}
const maxD = Math.min(W, H) * 0.12; ctx.lineWidth = 0.5
const maxD = Math.min(W, H) * 0.12 * cfg.linkDist; 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, dy = (PTS[i].y - PTS[j].y) * H
@@ -145,19 +158,21 @@ export default function AudioBackground() {
}
}
// ── AURORA — subtle shimmer, not a flood ──────────────────────────────────
// ── AURORA ────────────────────────────────────────────────────────────────
const drawAurora = () => {
const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 3500
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
const cfg = fxRef.current.aurora
const t = (Date.now() / 3500) * cfg.speed
const bri = cfg.brightness
clear(W, H, cfg.trail)
const bands = [
{ cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: 0.10 + smoothBass * 0.08, c: a },
{ cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: 0.08 + smoothMid * 0.07, c: '140,100,255' },
{ cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: 0.09 + smoothBass * 0.07, c: '255,60,172' },
{ cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: 0.07 + smoothMid * 0.06, c: a },
{ cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: 0.06 + smoothBass * 0.05, c: '140,100,255' },
{ cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: 0.05 + smoothMid * 0.05, c: '255,60,172' },
{ cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: (0.10 + smoothBass * 0.08) * bri, c: a },
{ cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: (0.08 + smoothMid * 0.07) * bri, c: '140,100,255' },
{ cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: (0.09 + smoothBass * 0.07) * bri, c: '255,60,172' },
{ cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: (0.07 + smoothMid * 0.06) * bri, c: a },
{ cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: (0.06 + smoothBass * 0.05) * bri, c: '140,100,255' },
{ cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: (0.05 + smoothMid * 0.05) * bri, c: '255,60,172' },
]
for (const band of bands) {
const cx = band.cx * W, cy = (band.cy + Math.sin(band.ph) * 0.07) * H
@@ -169,48 +184,68 @@ export default function AudioBackground() {
}
}
// ── PULSE — beat-driven expanding rings ───────────────────────────────────
// ── PULSE — expanding ring waves ──────────────────────────────────────────
const RINGS: Ring[] = []
let prevFast = 0
let lastRingMs = 0
let lastAmbMs = 0
const drawPulse = () => {
const W = canvas.width, H = canvas.height, a = ac()
const cfg = fxRef.current.pulse
const cx = W * 0.5, cy = H * 0.5
const maxR = Math.hypot(W, H) * 0.8
const maxR = Math.hypot(W, H) * 0.72
ctx.fillStyle = 'rgba(10,10,15,0.18)'; ctx.fillRect(0, 0, W, H)
clear(W, H, cfg.trail)
// Beat onset: fastBass rises sharply above smoothBass
const onset = fastBass > smoothBass + 0.10 && fastBass > 0.22 && fastBass > prevFast + 0.04
if (onset) RINGS.push({ r: 10, alpha: 0.7 + fastBass * 0.3, speed: 3 + fastBass * 9 })
prevFast = fastBass
const now = Date.now()
// Slow ambient rings so mode looks alive without audio
if (Math.random() < 0.008 && RINGS.length < 3) RINGS.push({ r: 5, alpha: 0.22, speed: 1.5 })
// Beat-triggered ring
const threshold = 0.45 - cfg.sensitivity * 0.3
if (fastBass > threshold && fastBass > smoothBass + 0.05 && now - lastRingMs > 200) {
RINGS.push({ r: 4, alpha: 1.0, speed: (5 + fastBass * 12) * cfg.ringSpeed })
lastRingMs = now
}
// Ambient ring on timer so it always looks alive even without audio
if (now - lastAmbMs > 1800) {
RINGS.push({ r: 4, alpha: 0.7, speed: 5 * cfg.ringSpeed })
lastAmbMs = now
}
for (let i = RINGS.length - 1; i >= 0; i--) {
const ring = RINGS[i]
ring.r += ring.speed
ring.alpha -= 0.008
if (ring.alpha <= 0 || ring.r > maxR) { RINGS.splice(i, 1); continue }
ring.alpha *= 0.982 // exponential fade — stays bright longer, fades smoothly
if (ring.alpha < 0.015 || ring.r > maxR) { RINGS.splice(i, 1); continue }
const lw = 2 + ring.alpha * 5
// Outer glow
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${a},${ring.alpha * 0.2})`
ctx.lineWidth = lw * 5; ctx.stroke()
// Main ring
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${a},${ring.alpha})`
ctx.lineWidth = 1.5 + ring.alpha * 3.5; ctx.stroke()
ctx.lineWidth = lw; ctx.stroke()
// Inner echo
if (ring.r > 40 && ring.alpha > 0.15) {
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.80, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.4})`
ctx.lineWidth = 0.8; ctx.stroke()
// Pink echo
if (ring.r > 30 && ring.alpha > 0.08) {
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.85, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.35})`
ctx.lineWidth = lw * 0.55; ctx.stroke()
}
}
// Persistent center glow
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80 + smoothBass * 120)
cg.addColorStop(0, `rgba(${a},${0.08 + smoothBass * 0.14})`); cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
// Center dot — pulses with bass, small footprint so it doesn't drown rings
const dotR = 14 + smoothBass * 55
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, dotR)
cg.addColorStop(0, `rgba(${a},${0.85 + smoothBass * 0.15})`)
cg.addColorStop(0.45,`rgba(${a},0.25)`)
cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg
ctx.beginPath(); ctx.arc(cx, cy, dotR, 0, Math.PI * 2); ctx.fill()
}
// ── STARS ─────────────────────────────────────────────────────────────────
@@ -220,22 +255,24 @@ export default function AudioBackground() {
}))
const drawStars = () => {
const W = canvas.width, H = canvas.height, a = ac()
const cfg = fxRef.current.stars
const t = Date.now() / 1000
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
const bri = cfg.brightness
clear(W, H, cfg.trail)
const n1 = ctx.createRadialGradient(W*0.30, H*0.35, 0, W*0.30, H*0.35, W*0.55)
n1.addColorStop(0, `rgba(${a},${0.04+smoothMid*0.04})`); n1.addColorStop(1, `rgba(${a},0)`)
n1.addColorStop(0, `rgba(${a},${(0.04+smoothMid*0.04)*bri})`); n1.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H)
for (const s of STARS) {
const tw = (Math.sin(t * s.sp + s.ph) + 1) * 0.5
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4)
const tw = (Math.sin(t * s.sp * cfg.twinkle + s.ph) + 1) * 0.5
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4) * bri
const r = s.r * (1 + smoothBass * tw * 1.0)
if (s.r > 1.1) { ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r*2.8, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha*0.18})`; ctx.fill() }
ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha})`; ctx.fill()
}
}
// ── RAIN — sparse, soft streaks ───────────────────────────────────────────
const DROPS: Drop[] = Array.from({ length: 30 }, () => ({
// ── RAIN ──────────────────────────────────────────────────────────────────
const DROPS: Drop[] = Array.from({ length: 60 }, () => ({
x: Math.random(), y: Math.random(),
speed: Math.random() * 0.0015 + 0.0008,
len: Math.random() * 0.055 + 0.03,
@@ -243,9 +280,12 @@ export default function AudioBackground() {
}))
const drawRain = () => {
const W = canvas.width, H = canvas.height, a = ac()
ctx.fillStyle = 'rgba(10,10,15,0.12)'; ctx.fillRect(0, 0, W, H)
const spd = 1 + smoothBass * 2.5
for (const d of DROPS) {
const cfg = fxRef.current.rain
const count = Math.round(cfg.drops)
clear(W, H, cfg.trail)
const spd = (1 + smoothBass * 2.5) * cfg.speed
for (let i = 0; i < count && i < DROPS.length; i++) {
const d = DROPS[i]
d.y += d.speed * spd
if (d.y > 1.08) { d.y = -d.len - 0.02; d.x = Math.random() }
const x = d.x * W, y = d.y * H, len = d.len * H * (1 + smoothBass * 0.4)
@@ -256,34 +296,29 @@ export default function AudioBackground() {
}
}
// ── RAYS — tapered beams from center ──────────────────────────────────────
// ── RAYS ──────────────────────────────────────────────────────────────────
const drawRays = () => {
const W = canvas.width, H = canvas.height, a = ac()
const cfg = raysRef.current
const cfg = fxRef.current.rays
const t = (Date.now() / 1000) * cfg.speed * 0.08
const cx = W * 0.5, cy = H * 0.55
const maxR = Math.hypot(W, H) * 1.1
const br = cfg.brightness
const sp = cfg.spread
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
clear(W, H, cfg.trail)
const count = cfg.count
// Primary rays — rotate forward
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + t
const isMain = i % 2 === 0
const hw = Math.tan((0.055 + smoothBass * 0.035) * sp) * maxR
const al = ((isMain ? 0.12 : 0.07) + smoothBass * 0.10 + smoothMid * 0.03) * br
const ex = cx + Math.cos(angle) * maxR
const ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw
const py = Math.cos(angle) * hw
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
grad.addColorStop(0.12, `rgba(${a},${al})`)
@@ -292,17 +327,14 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill()
}
// Secondary rays — counter-rotate, pink tint
const cnt2 = Math.max(2, Math.floor(count / 2))
for (let i = 0; i < cnt2; i++) {
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
const hw = Math.tan(0.035 * sp) * maxR
const al = (0.05 + smoothMid * 0.06) * br
const ex = cx + Math.cos(angle) * maxR
const ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw
const py = Math.cos(angle) * hw
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
@@ -312,7 +344,6 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill()
}
// Center glow
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 100 + smoothBass * 140)
cg.addColorStop(0, `rgba(${a},${(0.14 + smoothBass * 0.18) * br})`); cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)