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

@@ -7,25 +7,29 @@ import (
) )
type Config struct { type Config struct {
Port string Port string
DBHost string DBHost string
DBPort string DBPort string
DBUser string DBUser string
DBPass string DBPass string
DBName string DBName string
JWTSecret string JWTSecret string
AllowedOrigins string
CookieSecure bool
} }
func Load() *Config { func Load() *Config {
_ = godotenv.Load() _ = godotenv.Load()
return &Config{ return &Config{
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
DBHost: getEnv("DB_HOST", "localhost"), DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"), DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "partymix"), DBUser: getEnv("DB_USER", "partymix"),
DBPass: getEnv("DB_PASSWORD", "partymix"), DBPass: getEnv("DB_PASSWORD", "partymix"),
DBName: getEnv("DB_NAME", "partymix"), DBName: getEnv("DB_NAME", "partymix"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3001,http://localhost:3000"),
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
} }
} }

View File

@@ -12,6 +12,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const cookieName = "pm_token"
const cookieMaxAge = 60 * 60 * 24 * 30 // 30 days
type registerReq struct { type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=50"` Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
@@ -58,7 +61,7 @@ type loginReq struct {
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
} }
func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc { func Login(db *gorm.DB, jwtSecret string, cookieSecure bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var req loginReq var req loginReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -83,10 +86,15 @@ func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
return return
} }
c.JSON(http.StatusOK, gin.H{ c.SetCookie(cookieName, token, cookieMaxAge, "/", "", cookieSecure, true)
"token": token, c.JSON(http.StatusOK, gin.H{"user": user})
"user": user, }
}) }
func Logout(cookieSecure bool) gin.HandlerFunc {
return func(c *gin.Context) {
c.SetCookie(cookieName, "", -1, "/", "", cookieSecure, true)
c.JSON(http.StatusOK, gin.H{"ok": true})
} }
} }

View File

@@ -43,8 +43,13 @@ func parseToken(tokenStr, secret string) (*jwtClaims, error) {
func AuthRequired(jwtSecret string) gin.HandlerFunc { func AuthRequired(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
header := c.GetHeader("Authorization") // Cookie-first, Bearer header as fallback for backwards compat
tokenStr := strings.TrimPrefix(header, "Bearer ") tokenStr, err := c.Cookie(cookieName)
if err != nil || tokenStr == "" {
header := c.GetHeader("Authorization")
tokenStr = strings.TrimPrefix(header, "Bearer ")
}
if tokenStr == "" { if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return return

View File

@@ -0,0 +1,175 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type OverlayState struct {
Title string `json:"title"`
Artist string `json:"artist"`
Cover string `json:"cover"`
IsPlaying bool `json:"is_playing"`
Progress float64 `json:"progress"`
Duration float64 `json:"duration"`
Enabled bool `json:"enabled"`
Design string `json:"design"`
Style string `json:"style"`
AccentColor string `json:"accent_color"`
Position string `json:"position"`
Font string `json:"font"`
TextColor string `json:"text_color"`
ShowCover bool `json:"show_cover"`
ShowEq bool `json:"show_eq"`
Palette string `json:"palette"`
CustomBg string `json:"custom_bg"`
CustomText string `json:"custom_text"`
CustomText2 string `json:"custom_text2"`
CustomChroma string `json:"custom_chroma"`
CustomTitleBg string `json:"custom_title_bg"`
CustomBodyBg string `json:"custom_body_bg"`
Margin float64 `json:"margin"`
Scale float64 `json:"scale"`
Opacity float64 `json:"opacity"`
UpdatedAt int64 `json:"updated_at"`
}
type overlayEntry struct {
mu sync.RWMutex
state OverlayState
}
var overlayMap sync.Map // userID -> *overlayEntry
// ── SSE hub ───────────────────────────────────────────────────────────────────
type sseHub struct {
mu sync.RWMutex
clients map[chan OverlayState]struct{}
}
var sseHubs sync.Map // userID -> *sseHub
func getOrCreateOverlayEntry(userID string) *overlayEntry {
v, _ := overlayMap.LoadOrStore(userID, &overlayEntry{
state: OverlayState{Design: "minimal", Enabled: true, Scale: 1, Opacity: 1, Margin: 24},
})
return v.(*overlayEntry)
}
func getOrCreateHub(userID string) *sseHub {
v, _ := sseHubs.LoadOrStore(userID, &sseHub{
clients: make(map[chan OverlayState]struct{}),
})
return v.(*sseHub)
}
func broadcastOverlay(userID string, state OverlayState) {
hub, ok := sseHubs.Load(userID)
if !ok {
return
}
h := hub.(*sseHub)
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients {
select {
case ch <- state:
default:
}
}
}
// PUT /api/overlay/state (requires auth)
func PushOverlayState(c *gin.Context) {
userID := currentUserID(c)
var state OverlayState
if err := c.ShouldBindJSON(&state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
state.UpdatedAt = time.Now().UnixMilli()
entry := getOrCreateOverlayEntry(userID)
entry.mu.Lock()
entry.state = state
entry.mu.Unlock()
broadcastOverlay(userID, state)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/overlay/:token/state (public, fallback polling)
func GetOverlayState(c *gin.Context) {
token := c.Param("token")
v, ok := overlayMap.Load(token)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
entry := v.(*overlayEntry)
entry.mu.RLock()
state := entry.state
entry.mu.RUnlock()
c.JSON(http.StatusOK, state)
}
// GET /api/overlay/:token/stream (SSE, public)
func StreamOverlayState(c *gin.Context) {
token := c.Param("token")
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
// Send current state immediately
if v, ok := overlayMap.Load(token); ok {
entry := v.(*overlayEntry)
entry.mu.RLock()
state := entry.state
entry.mu.RUnlock()
data, _ := json.Marshal(state)
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
} else {
fmt.Fprintf(c.Writer, "event: notfound\ndata: {}\n\n")
}
c.Writer.Flush()
ch := make(chan OverlayState, 8)
hub := getOrCreateHub(token)
hub.mu.Lock()
hub.clients[ch] = struct{}{}
hub.mu.Unlock()
defer func() {
hub.mu.Lock()
delete(hub.clients, ch)
hub.mu.Unlock()
close(ch)
}()
ctx := c.Request.Context()
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
for {
select {
case state, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(state)
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
c.Writer.Flush()
case <-ticker.C:
fmt.Fprintf(c.Writer, ": keepalive\n\n")
c.Writer.Flush()
case <-ctx.Done():
return
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@@ -378,6 +379,167 @@ func YandexPlaylistHandler(c *gin.Context) {
}) })
} }
// ── Spotify ───────────────────────────────────────────────────────────────────
var (
spotifyTokenMu sync.Mutex
spotifyTokenVal string
spotifyTokenExp time.Time
)
func getSpotifyToken() (string, error) {
spotifyTokenMu.Lock()
defer spotifyTokenMu.Unlock()
if spotifyTokenVal != "" && time.Now().Before(spotifyTokenExp) {
return spotifyTokenVal, nil
}
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("spotify credentials not configured")
}
body := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", body)
if err != nil {
return "", err
}
req.SetBasicAuth(clientID, clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var tok struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", err
}
spotifyTokenVal = tok.AccessToken
spotifyTokenExp = time.Now().Add(time.Duration(tok.ExpiresIn-30) * time.Second)
return spotifyTokenVal, nil
}
func SpotifyPlaylistHandler(c *gin.Context) {
rawURL := c.Query("url")
if rawURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
var playlistID string
if strings.HasPrefix(rawURL, "spotify:playlist:") {
playlistID = strings.TrimPrefix(rawURL, "spotify:playlist:")
} else {
u, err := url.Parse(rawURL)
if err != nil || !strings.Contains(u.Host, "spotify.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spotify url"})
return
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i, p := range parts {
if p == "playlist" && i+1 < len(parts) {
playlistID = parts[i+1]
break
}
}
}
if playlistID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract playlist id"})
return
}
// strip query params from id if present
if idx := strings.IndexAny(playlistID, "?#"); idx >= 0 {
playlistID = playlistID[:idx]
}
token, err := getSpotifyToken()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
doReq := func(apiURL string) ([]byte, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("spotify returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// Fetch playlist name
nameBody, err := doReq("https://api.spotify.com/v1/playlists/" + url.PathEscape(playlistID) + "?fields=name")
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
var plMeta struct {
Name string `json:"name"`
}
_ = json.Unmarshal(nameBody, &plMeta)
// Fetch tracks (paginated, up to 500)
type spArtist struct{ Name string `json:"name"` }
type spTrack struct {
Name string `json:"name"`
Artists []spArtist `json:"artists"`
}
type spItem struct {
Track *spTrack `json:"track"`
}
type spPage struct {
Items []spItem `json:"items"`
Next *string `json:"next"`
}
tracks := make([]string, 0, 100)
nextURL := fmt.Sprintf(
"https://api.spotify.com/v1/playlists/%s/tracks?fields=items(track(name,artists(name))),next&limit=100",
url.PathEscape(playlistID),
)
for nextURL != "" && len(tracks) < 500 {
pageBody, err := doReq(nextURL)
if err != nil {
break
}
var page spPage
if err := json.Unmarshal(pageBody, &page); err != nil {
break
}
for _, item := range page.Items {
if item.Track == nil || item.Track.Name == "" {
continue
}
t := item.Track.Name
if len(item.Track.Artists) > 0 && item.Track.Artists[0].Name != "" {
t = item.Track.Artists[0].Name + " — " + item.Track.Name
}
tracks = append(tracks, t)
}
if page.Next != nil {
nextURL = *page.Next
} else {
nextURL = ""
}
}
c.JSON(http.StatusOK, gin.H{
"name": plMeta.Name,
"tracks": tracks,
})
}
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) { func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
if redirectCount > 5 { if redirectCount > 5 {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)

View File

@@ -52,11 +52,11 @@ type User struct {
type Playlist struct { type Playlist struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index" json:"user_id"` UserID string `gorm:"not null;index;index:idx_playlist_user_created,composite:1" json:"user_id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
IsPublic bool `gorm:"default:false" json:"is_public"` IsPublic bool `gorm:"default:false;index:idx_playlist_public_created,composite:1" json:"is_public"`
Tags string `gorm:"default:''" json:"-"` Tags string `gorm:"default:''" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `gorm:"index:idx_playlist_public_created,composite:2;index:idx_playlist_user_created,composite:2" json:"created_at"`
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"` Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
} }

View File

@@ -2,22 +2,29 @@ package router
import ( import (
"net/http" "net/http"
"strings"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/handlers" "github.com/toyffee/party-mix/internal/handlers"
"gorm.io/gorm" "gorm.io/gorm"
) )
func New(db *gorm.DB, jwtSecret string) *gin.Engine { func New(db *gorm.DB, cfg *config.Config) *gin.Engine {
r := gin.Default() r := gin.Default()
origins := strings.Split(cfg.AllowedOrigins, ",")
for i, o := range origins {
origins[i] = strings.TrimSpace(o)
}
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowAllOrigins: true, AllowOrigins: origins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"}, ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
AllowCredentials: false, AllowCredentials: true,
})) }))
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
@@ -31,25 +38,27 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
proxy.GET("/img", handlers.ImgProxyHandler) proxy.GET("/img", handlers.ImgProxyHandler)
proxy.GET("/mp3", handlers.MP3ProxyHandler) proxy.GET("/mp3", handlers.MP3ProxyHandler)
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler) proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
proxy.GET("/spotify-playlist", handlers.SpotifyPlaylistHandler)
} }
auth := r.Group("/api/auth") auth := r.Group("/api/auth")
{ {
auth.POST("/register", handlers.Register(db)) auth.POST("/register", handlers.Register(db))
auth.POST("/login", handlers.Login(db, jwtSecret)) auth.POST("/login", handlers.Login(db, cfg.JWTSecret, cfg.CookieSecure))
auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db)) auth.POST("/logout", handlers.Logout(cfg.CookieSecure))
auth.GET("/me", handlers.AuthRequired(cfg.JWTSecret), handlers.Me(db))
} }
r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db)) r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db))
versions := r.Group("/api/versions", handlers.AuthRequired(jwtSecret)) versions := r.Group("/api/versions", handlers.AuthRequired(cfg.JWTSecret))
{ {
versions.GET("", handlers.GetVersions(db)) versions.GET("", handlers.GetVersions(db))
versions.POST("", handlers.SaveVersion(db)) versions.POST("", handlers.SaveVersion(db))
versions.DELETE("", handlers.DeleteVersion(db)) versions.DELETE("", handlers.DeleteVersion(db))
} }
playlists := r.Group("/api/playlists", handlers.AuthRequired(jwtSecret)) playlists := r.Group("/api/playlists", handlers.AuthRequired(cfg.JWTSecret))
{ {
playlists.GET("", handlers.GetPlaylists(db)) playlists.GET("", handlers.GetPlaylists(db))
playlists.POST("", handlers.CreatePlaylist(db)) playlists.POST("", handlers.CreatePlaylist(db))
@@ -59,6 +68,13 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db)) playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db))
} }
overlay := r.Group("/api/overlay")
{
overlay.PUT("/state", handlers.AuthRequired(cfg.JWTSecret), handlers.PushOverlayState)
overlay.GET("/:token/state", handlers.GetOverlayState)
overlay.GET("/:token/stream", handlers.StreamOverlayState)
}
remote := r.Group("/api/remote") remote := r.Group("/api/remote")
{ {
remote.POST("", handlers.CreateRemoteRoom) remote.POST("", handlers.CreateRemoteRoom)

View File

@@ -11,7 +11,7 @@ import (
func main() { func main() {
cfg := config.Load() cfg := config.Load()
db := database.Connect(cfg) db := database.Connect(cfg)
r := router.New(db, cfg.JWTSecret) r := router.New(db, cfg)
log.Printf("Party Mix backend starting on :%s", cfg.Port) log.Printf("Party Mix backend starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil { if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err) log.Fatalf("server failed: %v", err)

68
apps/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,68 @@
events {
worker_connections 1024;
}
http {
# SSE and MP3 streaming — no buffering, long timeouts
upstream backend {
server backend:8080;
}
upstream web {
server web:3000;
}
server {
listen 80;
client_max_body_size 16m;
# SSE overlay stream
location ~ ^/api/overlay/[^/]+/stream {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_set_header Connection '';
chunked_transfer_encoding on;
}
# MP3 proxy — large bodies, streaming
location /api/proxy/mp3 {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
# All other API calls
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
}
# Next.js app (with WebSocket for hot reload in dev)
location / {
proxy_pass http://web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_read_timeout 60s;
}
}
}

5
apps/web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
*.log
.env*.local

View File

@@ -9,6 +9,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080 ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner

6
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,559 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useAuthStore } from '@/store/authStore'
import { getPublicPlaylists, createPlaylist } from '@/lib/authApi'
import type { PublicPlaylist, PlaylistTrack } from '@/types'
import Header from '@/components/Header'
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
function tagColor(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
}
type SortMode = 'newest' | 'tracks' | 'alpha'
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
return (
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
<div className="flex flex-col gap-0.5">
{tracks.map((track, i) => (
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
</div>
))}
</div>
</div>
)
}
function PlaylistCard({
pl, onPlay, isLaunched, onFork, isForkDone, isForkLoading, canFork,
}: {
pl: PublicPlaylist
onPlay: () => void
isLaunched: boolean
onFork: () => void
isForkDone: boolean
isForkLoading: boolean
canFork: boolean
}) {
const [expanded, setExpanded] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
<div className="flex items-center gap-3 px-4 py-3.5">
<div
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{pl.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
<span className="text-muted/30 text-[11px]">·</span>
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
{tags.map(tag => (
<span
key={tag}
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
>
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{trackCount > 0 && (
<button
onClick={() => setExpanded(v => !v)}
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 duration-150 cursor-pointer"
title={expanded ? 'Скрыть треки' : 'Показать треки'}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
{canFork && trackCount > 0 && (
<button
onClick={onFork}
disabled={isForkDone || isForkLoading}
className="w-7 h-7 rounded-[8px] flex items-center justify-center transition-all duration-150 cursor-pointer disabled:cursor-default"
style={isForkDone ? { background: 'rgba(107,255,184,0.12)', color: '#6bffb8' } : { color: 'var(--muted)' }}
title={isForkDone ? 'Добавлено!' : 'Скопировать к себе'}
>
{isForkLoading ? (
<div className="w-3 h-3 rounded-full border-[1.5px] border-white/20 border-t-accent animate-spin" />
) : isForkDone ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="hover:text-app-text">
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M12 13v4M10 15h4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)}
</button>
)}
<button
onClick={onPlay}
disabled={!trackCount}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
}}
>
{isLaunched ? '▶ Играет' : '▶ Play'}
</button>
</div>
</div>
{expanded && pl.tracks && pl.tracks.length > 0 && <TrackList tracks={pl.tracks} />}
</div>
)
}
function SortButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer whitespace-nowrap"
style={active
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)' }
}
>
{children}
</button>
)
}
function TagToggleButton({
count, open, onClick,
}: {
count: number
open: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-3 py-2 rounded-[11px] transition-all duration-150 cursor-pointer border whitespace-nowrap shrink-0"
style={count > 0
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.25)' }
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="7" y1="7" x2="7.01" y2="7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
Теги
{count > 0 && (
<span
className="flex items-center justify-center w-4 h-4 rounded-full text-[10px] font-extrabold leading-none"
style={{ background: 'var(--accent)', color: '#0a0a0f' }}
>
{count}
</span>
)}
<svg width="9" height="9" viewBox="0 0 12 12" fill="none"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }}>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)
}
const TAG_VISIBLE_DEFAULT = 12
function TagPanel({
allTags,
activeTags,
tagCounts,
onToggle,
onClear,
}: {
allTags: string[]
activeTags: Set<string>
tagCounts: Record<string, number>
onToggle: (tag: string) => void
onClear: () => void
}) {
const [tagSearch, setTagSearch] = useState('')
const [showAll, setShowAll] = useState(false)
const count = activeTags.size
const sorted = [...allTags].sort((a, b) => (tagCounts[b] ?? 0) - (tagCounts[a] ?? 0))
const q = tagSearch.toLowerCase().trim()
const matched = q ? sorted.filter(t => t.toLowerCase().includes(q)) : sorted
const visible = q || showAll ? matched : matched.slice(0, TAG_VISIBLE_DEFAULT)
const hiddenCount = matched.length - TAG_VISIBLE_DEFAULT
return (
<div className="p-3 bg-surface border border-white/[0.07] rounded-[14px]">
<div className="flex items-center gap-2 mb-2.5">
<div className="relative flex-1">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="11" height="11" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={tagSearch}
onChange={e => setTagSearch(e.target.value)}
placeholder="Найти тег..."
className="w-full font-sans text-[12px] bg-white/[0.04] border border-white/[0.07] rounded-[9px] pl-7 pr-2.5 py-1.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{tagSearch && (
<button
onClick={() => setTagSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{count > 0 && (
<button
onClick={onClear}
className="text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer whitespace-nowrap shrink-0"
>
Сбросить
</button>
)}
</div>
{matched.length === 0 ? (
<p className="text-[12px] text-muted text-center py-2">Не найдено</p>
) : (
<>
<div className="flex flex-wrap gap-1.5">
{visible.map(tag => {
const active = activeTags.has(tag)
const color = tagColor(tag)
return (
<button
key={tag}
onClick={() => onToggle(tag)}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1.5 rounded-[9px] transition-all duration-150 cursor-pointer"
style={active
? { background: color, color: '#0a0a0f' }
: { background: `${color}14`, color: color, border: `1px solid ${color}30` }
}
>
{active && (
<svg width="9" height="9" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
{tag}
<span className="text-[10px] font-sans font-medium opacity-60 leading-none">
{tagCounts[tag] ?? 0}
</span>
</button>
)
})}
</div>
{!q && hiddenCount > 0 && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-2.5 text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer"
>
{showAll ? 'Скрыть' : `Ещё ${hiddenCount} тегов`}
</button>
)}
</>
)}
</div>
)
}
export default function CommunityPage() {
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
const [loading, setLoading] = useState(true)
const [launched, setLaunched] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<Set<string>>(new Set())
const [tagsOpen, setTagsOpen] = useState(false)
const [sort, setSort] = useState<SortMode>('newest')
const [forkStates, setForkStates] = useState<Record<string, 'idle' | 'loading' | 'done'>>({})
const { loadPlaylist } = usePartyStore()
const { user } = useAuthStore()
useEffect(() => {
getPublicPlaylists()
.then(setPlaylists)
.catch(() => setPlaylists([]))
.finally(() => setLoading(false))
}, [])
const handlePlay = (pl: PublicPlaylist) => {
const tracks = pl.tracks?.map(t => t.title) ?? []
if (!tracks.length) return
loadPlaylist(tracks)
setLaunched(pl.id)
setTimeout(() => setLaunched(null), 2500)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handlePlayAll = () => {
const tracks = filtered.flatMap(pl => pl.tracks?.map(t => t.title) ?? [])
if (!tracks.length) return
loadPlaylist(tracks)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleFork = useCallback(async (pl: PublicPlaylist) => {
if (!user || forkStates[pl.id] === 'loading' || forkStates[pl.id] === 'done') return
const tracks = pl.tracks?.map(t => t.title) ?? []
if (!tracks.length) return
setForkStates(s => ({ ...s, [pl.id]: 'loading' }))
try {
await createPlaylist(`${pl.name} (от ${pl.username})`, tracks, false, pl.tags ?? [])
setForkStates(s => ({ ...s, [pl.id]: 'done' }))
} catch {
setForkStates(s => ({ ...s, [pl.id]: 'idle' }))
}
}, [user, forkStates])
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
const tagCounts = allTags.reduce<Record<string, number>>((acc, tag) => {
acc[tag] = playlists.filter(pl => (pl.tags ?? []).includes(tag)).length
return acc
}, {})
const totalTracks = playlists.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
const uniqueAuthors = new Set(playlists.map(pl => pl.username)).size
const filtered = playlists
.filter(pl => {
const q = search.toLowerCase().trim()
const matchesSearch = !q
|| pl.name.toLowerCase().includes(q)
|| pl.username.toLowerCase().includes(q)
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
const matchesTags = activeTags.size === 0 || (pl.tags ?? []).some(t => activeTags.has(t))
return matchesSearch && matchesTags
})
.sort((a, b) => {
if (sort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
if (sort === 'alpha') return a.name.localeCompare(b.name, 'ru')
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
const filteredTracks = filtered.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
const toggleTag = (tag: string) => {
setActiveTags(prev => {
const next = new Set(prev)
next.has(tag) ? next.delete(tag) : next.add(tag)
return next
})
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="mb-5">
<div className="flex items-baseline gap-2.5">
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
{!loading && (
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
)}
</div>
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
</div>
{!loading && playlists.length > 0 && (
<>
{/* Stats bar */}
<div className="flex items-center gap-4 mb-4 px-4 py-2.5 bg-surface border border-white/[0.06] rounded-app">
<div className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
<path d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12 0c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-[12px] font-bold text-app-text">{filteredTracks}</span>
<span className="text-[11px] text-muted">треков</span>
</div>
<div className="w-px h-3.5 bg-white/[0.07]" />
<div className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="9" cy="7" r="4" stroke="currentColor" strokeWidth="1.8" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<span className="text-[12px] font-bold text-app-text">{new Set(filtered.map(pl => pl.username)).size}</span>
<span className="text-[11px] text-muted">{new Set(filtered.map(pl => pl.username)).size === 1 ? 'автор' : new Set(filtered.map(pl => pl.username)).size < 5 ? 'автора' : 'авторов'}</span>
</div>
<div className="w-px h-3.5 bg-white/[0.07]" />
<div className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 9h18M9 21V9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<span className="text-[12px] font-bold text-app-text">{filtered.length}</span>
<span className="text-[11px] text-muted">{filtered.length === 1 ? 'плейлист' : filtered.length < 5 ? 'плейлиста' : 'плейлистов'}</span>
</div>
</div>
{/* Search + sort + tags */}
<div className="mb-4 flex flex-col gap-2">
<div className="flex gap-2">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по названию, автору или тегу..."
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{allTags.length > 0 && (
<TagToggleButton
count={activeTags.size}
open={tagsOpen}
onClick={() => setTagsOpen(v => !v)}
/>
)}
</div>
{tagsOpen && allTags.length > 0 && (
<TagPanel
allTags={allTags}
activeTags={activeTags}
tagCounts={tagCounts}
onToggle={toggleTag}
onClear={() => setActiveTags(new Set())}
/>
)}
{/* Sort */}
<div className="flex items-center gap-1">
<SortButton active={sort === 'newest'} onClick={() => setSort('newest')}>Новые</SortButton>
<SortButton active={sort === 'tracks'} onClick={() => setSort('tracks')}>По трекам</SortButton>
<SortButton active={sort === 'alpha'} onClick={() => setSort('alpha')}>АЯ</SortButton>
</div>
</div>
{/* Active tag chips */}
{activeTags.size > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{Array.from(activeTags).map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className="flex items-center gap-1 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
style={{ background: tagColor(tag), color: '#0a0a0f' }}
>
{tag}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
</button>
))}
<button
onClick={() => setActiveTags(new Set())}
className="text-[11px] text-muted hover:text-app-text transition-colors cursor-pointer px-1"
>
Сбросить всё
</button>
</div>
)}
</>
)}
{loading ? (
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
Загрузка...
</div>
) : !filtered.length ? (
<div className="text-center py-14 text-muted">
<div className="text-4xl mb-3 opacity-20">🎵</div>
<p className="text-[13px] font-medium">
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
</p>
<p className="text-[12px] mt-1.5 opacity-50">
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
</p>
</div>
) : (
<>
<div className="flex items-center justify-between mb-2.5">
<span className="text-[11px] text-muted">
{filtered.length !== playlists.length
? `${filtered.length} из ${playlists.length} · ${filteredTracks} треков`
: `${filtered.length} плейлистов · ${filteredTracks} треков`
}
</span>
<button
onClick={handlePlayAll}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3l14 9-14 9V3z" />
</svg>
Играть всё
</button>
</div>
<div className="flex flex-col gap-2.5">
{filtered.map(pl => (
<PlaylistCard
key={pl.id}
pl={pl}
onPlay={() => handlePlay(pl)}
isLaunched={launched === pl.id}
onFork={() => handleFork(pl)}
isForkDone={forkStates[pl.id] === 'done'}
isForkLoading={forkStates[pl.id] === 'loading'}
canFork={!!user && pl.username !== user.username}
/>
))}
</div>
</>
)}
</main>
)
}

View File

@@ -0,0 +1,20 @@
import AuthHydrator from '@/components/AuthHydrator'
import AudioBackground from '@/components/AudioBackground'
import GlobalPlayer from '@/components/GlobalPlayer'
import ThemeApplier from '@/components/ThemeApplier'
import Toaster from '@/components/Toaster'
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="bg-bg min-h-screen">
<ThemeApplier />
<AudioBackground />
<div className="relative pb-[72px] px-4 pt-5 sm:px-4" style={{ zIndex: 1 }}>
<AuthHydrator />
{children}
</div>
<GlobalPlayer />
<Toaster />
</div>
)
}

View File

@@ -31,9 +31,9 @@ export default function LoginPage() {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
const { token, user } = await login(email, password) const user = await login(email, password)
setAuth(token, user) setAuth(user)
router.push('/') router.push('/app')
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка входа') setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally { } finally {

View File

@@ -0,0 +1,212 @@
import Link from 'next/link'
const EQ_BARS = [
{ h: 35, d: 0 }, { h: 72, d: 0.12 }, { h: 50, d: 0.23 }, { h: 90, d: 0.06 },
{ h: 55, d: 0.17 }, { h: 82, d: 0.28 }, { h: 44, d: 0.09 }, { h: 96, d: 0.20 },
{ h: 60, d: 0.14 }, { h: 76, d: 0.03 }, { h: 40, d: 0.25 }, { h: 85, d: 0.18 },
{ h: 52, d: 0.07 }, { h: 67, d: 0.30 }, { h: 30, d: 0.11 }, { h: 78, d: 0.22 },
{ h: 48, d: 0.16 }, { h: 88, d: 0.04 }, { h: 62, d: 0.26 }, { h: 38, d: 0.19 },
]
const STEPS = [
{
n: '01',
title: 'Создай вечеринку',
desc: 'Открой плеер, добавь гостей — каждый получает свой цвет',
},
{
n: '02',
title: 'Добавьте треки',
desc: 'Каждый гость пишет своё — плеер сам найдёт и поставит',
},
{
n: '03',
title: 'Включай музыку',
desc: 'Умный шаффл чередует очередь — никто не обделён эфиром',
},
]
const FEATURES = [
{
title: 'Совместная очередь',
desc: 'Каждый гость добавляет треки — никто не обделён эфиром',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1.2" fill="currentColor" stroke="none" />
<circle cx="3" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="3" cy="18" r="1.2" fill="currentColor" stroke="none" />
</svg>
),
},
{
title: 'Умный шаффл',
desc: 'Честный режим или случайный — музыка для всех, без диктатуры',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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" />
<line x1="4" y1="4" x2="9" y2="9" />
</svg>
),
},
{
title: 'Плейлисты',
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
),
},
{
title: 'Пульт с телефона',
desc: 'Управляй плеером с любого телефона по QR-ссылке — без установки',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" />
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
</svg>
),
},
]
export default function LandingPage() {
return (
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
{/* Nav */}
<nav className="flex items-center justify-between py-3 mb-2">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<span className="font-display font-extrabold text-lg tracking-tight">
Party<span className="text-accent">Mix</span>
</span>
</div>
<div className="flex items-center gap-2">
<Link href="/login" className="text-[13px] font-sans text-muted hover:text-app-text transition-colors px-2 py-1">
Войти
</Link>
<Link href="/register" className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all">
Регистрация
</Link>
</div>
</nav>
{/* Hero */}
<section className="flex flex-col items-center text-center pt-10 pb-4 relative">
{/* Glow */}
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[300px] pointer-events-none" aria-hidden="true">
<div className="absolute inset-0 rounded-full blur-3xl" style={{ background: 'rgba(var(--accent-rgb),0.07)' }} />
</div>
{/* EQ */}
<div className="relative flex items-end gap-[4px] mb-7 h-14" aria-hidden="true">
{EQ_BARS.map(({ h, d }, i) => (
<div
key={i}
className="eq-bar"
style={{ height: `${h}%`, animationDelay: `${d}s`, animationDuration: `${0.6 + d * 0.8}s`, width: '3px' }}
/>
))}
</div>
{/* Title */}
<h1 className="font-display font-extrabold leading-none tracking-tight mb-4 text-[68px] sm:text-[96px]">
Party<span className="text-accent">Mix</span>
</h1>
{/* Tagline */}
<p className="font-sans text-base text-muted max-w-[280px] mb-8 leading-relaxed">
Совместные плейлисты для вечеринок.<br />
Каждый гость&nbsp; часть музыки.
</p>
{/* CTA */}
<div className="flex items-center gap-3 flex-wrap justify-center mb-12">
<Link
href="/app"
className="px-7 py-3 font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all"
style={{ background: 'var(--accent)', color: '#0a0a0f', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3)' }}
>
Начать вечеринку
</Link>
<Link
href="/login"
className="px-7 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all"
>
Войти
</Link>
</div>
</section>
{/* Steps */}
<section className="py-10">
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Как начать</p>
<div className="flex flex-col gap-4">
{STEPS.map(({ n, title, desc }) => (
<div key={n} className="flex items-start gap-4">
<span
className="text-[11px] font-display font-bold shrink-0 mt-0.5 w-8 h-8 rounded-[8px] flex items-center justify-center"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{n}
</span>
<div>
<div className="text-[14px] font-display font-bold mb-0.5">{title}</div>
<div className="text-[12px] text-muted leading-relaxed">{desc}</div>
</div>
</div>
))}
</div>
</section>
{/* Divider */}
<div className="border-t border-white/[0.05] mb-8" />
{/* Features */}
<section className="pb-10">
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Возможности</p>
<div className="grid grid-cols-2 gap-3">
{FEATURES.map(({ icon, title, desc }) => (
<div
key={title}
className="bg-surface border border-white/[0.07] rounded-[14px] p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
>
<div
className="w-9 h-9 rounded-[9px] flex items-center justify-center mb-3"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{icon}
</div>
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
<p className="text-[11px] text-muted font-sans leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="border-t border-white/[0.05] pt-5 pb-4 flex items-center justify-between">
<span className="text-[12px] font-display font-bold tracking-tight text-muted">
Party<span style={{ color: 'var(--accent)' }}>Mix</span>
</span>
<div className="flex items-center gap-4">
<Link href="/search" className="text-[11px] text-muted hover:text-app-text transition-colors">Поиск</Link>
<Link href="/community" className="text-[11px] text-muted hover:text-app-text transition-colors">Сообщество</Link>
<Link href="/app" className="text-[11px] text-muted hover:text-app-text transition-colors">Плеер</Link>
</div>
</footer>
</div>
)
}

View File

@@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/store/favoritesStore'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
import { useVersionStore } from '@/store/versionStore' import { useVersionStore } from '@/store/versionStore'
import { getPlaylists, createPlaylist, updatePlaylist, deletePlaylist } from '@/lib/authApi' import { getPlaylists, createPlaylist, updatePlaylist, deletePlaylist } from '@/lib/authApi'
import { searchTracks, proxyImgUrl, fetchYandexPlaylist } from '@/lib/api' import { searchTracks, proxyImgUrl, fetchYandexPlaylist, fetchSpotifyPlaylist } from '@/lib/api'
import type { Playlist, SearchResult } from '@/types' import type { Playlist, SearchResult } from '@/types'
import Header from '@/components/Header' import Header from '@/components/Header'
@@ -161,16 +161,21 @@ function PlaylistCard({
pl, pl,
onEdit, onEdit,
onDelete, onDelete,
onAddTrack,
}: { }: {
pl: Playlist pl: Playlist
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onAddTrack: (id: string, title: string) => Promise<void>
}) { }) {
const { loadPlaylist } = usePartyStore() const { loadPlaylist } = usePartyStore()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [versionsFor, setVersionsFor] = useState<string | null>(null) const [versionsFor, setVersionsFor] = useState<string | null>(null)
const [launched, setLaunched] = useState(false) const [launched, setLaunched] = useState(false)
const [addingTrack, setAddingTrack] = useState(false)
const [quickAdd, setQuickAdd] = useState('')
const [quickAdding, setQuickAdding] = useState(false)
const tags = pl.tags ?? [] const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0 const trackCount = pl.tracks?.length ?? 0
@@ -201,6 +206,16 @@ function PlaylistCard({
setTimeout(() => setLaunched(false), 2500) setTimeout(() => setLaunched(false), 2500)
} }
const handleQuickAdd = async () => {
const t = quickAdd.trim()
if (!t) return
setQuickAdding(true)
await onAddTrack(pl.id, t)
setQuickAdd('')
setAddingTrack(false)
setQuickAdding(false)
}
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">
@@ -250,6 +265,14 @@ function PlaylistCard({
</svg> </svg>
</button> </button>
)} )}
<button
onClick={() => setAddingTrack(v => !v)}
title="Добавить трек"
className="w-7 h-7 rounded-[8px] flex items-center justify-center border transition-all cursor-pointer"
style={{ borderColor: addingTrack ? 'rgba(var(--accent-rgb),0.35)' : 'rgba(255,255,255,0.07)', color: addingTrack ? 'var(--accent)' : undefined, background: addingTrack ? 'rgba(var(--accent-rgb),0.06)' : 'transparent' }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></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">
Изменить Изменить
@@ -274,6 +297,27 @@ function PlaylistCard({
</div> </div>
</div> </div>
{addingTrack && (
<div className="flex gap-2 px-4 py-2.5 border-t border-white/[0.05] bg-surface2/30">
<input
autoFocus
type="text"
value={quickAdd}
onChange={e => setQuickAdd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleQuickAdd(); if (e.key === 'Escape') { setAddingTrack(false); setQuickAdd('') } }}
placeholder="Исполнитель — Название"
className="flex-1 text-[12px] bg-surface2 border border-white/[0.07] rounded-[8px] px-3 py-2 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={handleQuickAdd}
disabled={!quickAdd.trim() || quickAdding}
className="px-3 py-2 text-[12px] font-display font-bold rounded-[8px] bg-accent text-bg hover:brightness-110 disabled:opacity-40 cursor-pointer transition-all"
>
{quickAdding ? '...' : 'Добавить'}
</button>
</div>
)}
{expanded && pl.tracks && pl.tracks.length > 0 && ( {expanded && pl.tracks && pl.tracks.length > 0 && (
<div className="border-t border-white/[0.05]"> <div className="border-t border-white/[0.05]">
{pl.tracks.map((track, i) => ( {pl.tracks.map((track, i) => (
@@ -491,7 +535,7 @@ function FavoritesCard() {
) )
} }
function YandexImportForm({ onImport, onClose }: { function YandexImportForm({ onImport }: {
onImport: (name: string, tracks: string[]) => Promise<void> onImport: (name: string, tracks: string[]) => Promise<void>
onClose: () => void onClose: () => void
}) { }) {
@@ -533,18 +577,7 @@ function YandexImportForm({ onImport, onClose }: {
} }
return ( return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5"> <div className="pt-3">
<div className="flex items-center justify-between mb-3">
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Импорт из Яндекс.Музыки
</p>
<button onClick={onClose} className="text-muted hover:text-app-text transition-colors cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mb-3">
<input <input
type="url" type="url"
@@ -609,12 +642,121 @@ function YandexImportForm({ onImport, onClose }: {
) )
} }
function SpotifyImportForm({ onImport, onClose }: {
onImport: (name: string, tracks: string[]) => Promise<void>
onClose: () => void
}) {
const [importUrl, setImportUrl] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [preview, setPreview] = useState<{ name: string; tracks: string[] } | null>(null)
const [playlistName, setPlaylistName] = useState('')
const [creating, setCreating] = useState(false)
const handleLoad = async () => {
if (!importUrl.trim()) return
setLoading(true)
setError('')
setPreview(null)
try {
const data = await fetchSpotifyPlaylist(importUrl.trim())
if (!data.tracks.length) {
setError('Плейлист пуст или не удалось получить треки')
return
}
setPreview(data)
setPlaylistName(data.name || 'Плейлист из Spotify')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
if (!preview || !playlistName.trim()) return
setCreating(true)
try {
await onImport(playlistName.trim(), preview.tracks)
} finally {
setCreating(false)
}
}
return (
<div className="pt-3">
<div className="flex gap-2 mb-3">
<input
type="url"
value={importUrl}
onChange={e => { setImportUrl(e.target.value); setPreview(null); setError('') }}
onKeyDown={e => e.key === 'Enter' && handleLoad()}
placeholder="https://open.spotify.com/playlist/..."
className="flex-1 font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={handleLoad}
disabled={loading || !importUrl.trim()}
className="px-3.5 py-2.5 font-display text-[12px] font-bold rounded-[9px] border border-white/[0.1] text-muted hover:text-app-text hover:border-white/20 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0 flex items-center gap-1.5"
>
{loading ? (
<><div className="w-3 h-3 rounded-full border-2 border-surface2 border-t-accent animate-spin" /> Загрузка</>
) : 'Загрузить'}
</button>
</div>
{error && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg mb-3">
{error}
</div>
)}
{preview && (
<>
<div className="bg-surface2 border border-white/[0.06] rounded-[9px] p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-muted">Найдено треков:</span>
<span className="text-[11px] font-display font-bold" style={{ color: 'var(--accent)' }}>{preview.tracks.length}</span>
</div>
<div className="max-h-[140px] overflow-y-auto flex flex-col gap-0.5">
{preview.tracks.map((t, i) => (
<div key={i} className="flex items-center gap-2 py-0.5">
<span className="text-[10px] text-muted/40 font-mono w-4 text-right shrink-0">{i + 1}</span>
<span className="text-[11px] text-app-text/70 truncate">{t}</span>
</div>
))}
</div>
</div>
<input
type="text"
value={playlistName}
onChange={e => setPlaylistName(e.target.value)}
className="w-full font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 mb-3 transition-colors"
placeholder="Название плейлиста"
/>
<button
onClick={handleCreate}
disabled={creating || !playlistName.trim()}
className="w-full py-2.5 font-display text-[13px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{creating ? 'Создаём...' : `Создать плейлист (${preview.tracks.length} треков)`}
</button>
</>
)}
</div>
)
}
export default function PlaylistsPage() { export default function PlaylistsPage() {
const router = useRouter() const router = useRouter()
const { token, user } = useAuthStore() const { user } = useAuthStore()
const { hydrate: hydrateFavorites } = useFavoritesStore() const { hydrate: hydrateFavorites } = useFavoritesStore()
const [playlists, setPlaylists] = useState<Playlist[]>([]) const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [plSearch, setPlSearch] = useState('')
const [plSort, setPlSort] = useState<'date' | 'name' | 'tracks'>('date')
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [newTracks, setNewTracks] = useState('') const [newTracks, setNewTracks] = useState('')
@@ -626,30 +768,31 @@ export default function PlaylistsPage() {
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false) const [showImport, setShowImport] = useState(false)
const [importSource, setImportSource] = useState<'yandex' | 'spotify'>('yandex')
useEffect(() => { useEffect(() => {
hydrateFavorites() hydrateFavorites()
}, [hydrateFavorites]) }, [hydrateFavorites])
useEffect(() => { useEffect(() => {
if (!token) { router.push('/login'); return } if (!user) { router.push('/login'); return }
getPlaylists(token) getPlaylists()
.then(setPlaylists) .then(setPlaylists)
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [token, router]) }, [user, router])
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1) const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!token || !newName.trim()) return if (!user || !newName.trim()) return
const tracks = parseTracks(newTracks) const tracks = parseTracks(newTracks)
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return } if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
setCreateError('') setCreateError('')
setSaving(true) setSaving(true)
try { try {
const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags) const pl = await createPlaylist(newName.trim(), tracks, newIsPublic, newTags)
setPlaylists(prev => [pl, ...prev]) setPlaylists(prev => [pl, ...prev])
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([]) setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
setShowForm(false) setShowForm(false)
@@ -661,23 +804,39 @@ export default function PlaylistsPage() {
} }
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => { const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
if (!token) return if (!user) return
try { try {
const updated = await updatePlaylist(token, id, name, tracks, isPublic, tags) const updated = await updatePlaylist(id, name, tracks, isPublic, tags)
setPlaylists(prev => prev.map(p => p.id === id ? updated : p)) setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
setEditingId(null) setEditingId(null)
} catch {} } catch {}
} }
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!token) return if (!user) return
setPlaylists(prev => prev.filter(p => p.id !== id)) setPlaylists(prev => prev.filter(p => p.id !== id))
await deletePlaylist(token, id).catch(() => {}) await deletePlaylist(id).catch(() => {})
} }
const handleAddTrack = async (id: string, title: string) => {
const pl = playlists.find(p => p.id === id)
if (!pl) return
const existing = pl.tracks?.map(t => t.title) ?? []
const updated = await updatePlaylist(id, pl.name, [...existing, title], pl.is_public, pl.tags ?? [])
setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
}
const filteredPlaylists = playlists
.filter(p => !plSearch.trim() || p.name.toLowerCase().includes(plSearch.toLowerCase()))
.sort((a, b) => {
if (plSort === 'name') return a.name.localeCompare(b.name, 'ru')
if (plSort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
return new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()
})
const handleImport = async (name: string, tracks: string[]) => { const handleImport = async (name: string, tracks: string[]) => {
if (!token) return if (!user) return
const pl = await createPlaylist(token, name, tracks, false, []) const pl = await createPlaylist(name, tracks, false, [])
setPlaylists(prev => [pl, ...prev]) setPlaylists(prev => [pl, ...prev])
setShowImport(false) setShowImport(false)
} }
@@ -740,10 +899,33 @@ export default function PlaylistsPage() {
<FavoritesCard /> <FavoritesCard />
{showImport && ( {showImport && (
<YandexImportForm <div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
onImport={handleImport} <div className="flex items-center justify-between mb-3">
onClose={() => setShowImport(false)} <div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
/> {(['yandex', 'spotify'] as const).map(src => (
<button
key={src}
onClick={() => setImportSource(src)}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer"
style={importSource === src
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
: { background: 'transparent', color: 'var(--color-muted)' }}
>
{src === 'yandex' ? 'Яндекс' : 'Spotify'}
</button>
))}
</div>
<button onClick={() => setShowImport(false)} className="text-muted hover:text-app-text transition-colors cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
</div>
{importSource === 'yandex'
? <YandexImportForm onImport={handleImport} onClose={() => setShowImport(false)} />
: <SpotifyImportForm onImport={handleImport} onClose={() => setShowImport(false)} />
}
</div>
)} )}
{showForm && ( {showForm && (
@@ -801,24 +983,56 @@ export default function PlaylistsPage() {
<p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p> <p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-2.5"> <>
{playlists.map(pl => ( {/* Search + sort */}
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]"> <div className="flex gap-2 mb-3">
<PlaylistCard <div className="relative flex-1">
pl={pl} <svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)} <circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
onDelete={() => handleDelete(pl.id)} <path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={plSearch}
onChange={e => setPlSearch(e.target.value)}
placeholder="Поиск по плейлистам..."
className="w-full text-[12px] bg-surface border border-white/[0.07] rounded-[9px] pl-8 pr-3 py-2 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/> />
{editingId === pl.id && (
<EditForm
pl={pl}
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
onCancel={() => setEditingId(null)}
/>
)}
</div> </div>
))} <select
</div> value={plSort}
onChange={e => setPlSort(e.target.value as typeof plSort)}
className="text-[11px] bg-surface border border-white/[0.07] rounded-[9px] px-2.5 py-2 text-muted outline-none cursor-pointer"
>
<option value="date">По дате</option>
<option value="name">По имени</option>
<option value="tracks">По трекам</option>
</select>
</div>
<div className="flex flex-col gap-2.5">
{filteredPlaylists.map(pl => (
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
<PlaylistCard
pl={pl}
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
onDelete={() => handleDelete(pl.id)}
onAddTrack={handleAddTrack}
/>
{editingId === pl.id && (
<EditForm
pl={pl}
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
onCancel={() => setEditingId(null)}
/>
)}
</div>
))}
{filteredPlaylists.length === 0 && plSearch && (
<div className="text-center py-8 text-muted text-[13px]">Ничего не найдено</div>
)}
</div>
</>
)} )}
</main> </main>
) )

View File

@@ -0,0 +1,543 @@
'use client'
import { use, useEffect, useRef, useState, useCallback } from 'react'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
interface RemoteQueueItem {
title: string
owner: string
color_bg: string
color_text: string
img?: string
}
interface RemoteVersion {
title: string
artist: string
duration: string
img?: string
}
interface RemoteState {
title: string
artist: string
cover: string
is_playing: boolean
volume: number
progress: number
duration: number
queue_len: number
cur_idx: number
queue: RemoteQueueItem[]
versions: RemoteVersion[]
active_version: number
}
function formatTime(s: number) {
if (!s || isNaN(s)) return '0:00'
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
}
async function cmd(id: string, c: string, value?: number, text?: string) {
await fetch(`${API_URL}/api/remote/${id}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
}).catch(() => {})
}
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [state, setState] = useState<RemoteState | null>(null)
const [notFound, setNotFound] = useState(false)
const [volume, setVolume] = useState(1)
const [addText, setAddText] = useState('')
const [tab, setTab] = useState<'player' | 'queue'>('player')
const [versionsOpen, setVersionsOpen] = useState(false)
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
const [localProgress, setLocalProgress] = useState(0)
const [connected, setConnected] = useState(true)
const [seeking, setSeeking] = useState(false)
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
const lastSuccessRef = useRef(Date.now())
const progressBarRef = useRef<HTMLDivElement>(null)
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
try {
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
if (s) setSavedVersions(JSON.parse(s))
} catch {}
}, [])
useEffect(() => {
let active = true
const poll = async () => {
try {
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
if (res.status === 404) { setNotFound(true); return }
if (!res.ok) return
const data: RemoteState = await res.json()
if (active) {
setState(data)
if (!seeking) {
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
setLocalProgress(data.progress ?? 0)
}
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
lastSuccessRef.current = Date.now()
setConnected(true)
}
} catch {
if (active) setConnected(false)
}
}
poll()
const iv = setInterval(poll, 2000)
return () => { active = false; clearInterval(iv) }
}, [id, seeking])
useEffect(() => {
const iv = setInterval(() => {
if (seeking) return
const { progress, ts, playing } = lastPollRef.current
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
}, 250)
return () => clearInterval(iv)
}, [seeking])
const getSeekPct = useCallback((clientX: number) => {
const bar = progressBarRef.current
if (!bar || !state?.duration) return null
const rect = bar.getBoundingClientRect()
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}, [state?.duration])
const handleSeekStart = useCallback((clientX: number) => {
const pct = getSeekPct(clientX)
if (pct === null) return
setSeeking(true)
const seekTo = pct * (state?.duration ?? 0)
setLocalProgress(seekTo)
}, [getSeekPct, state?.duration])
const handleSeekMove = useCallback((clientX: number) => {
if (!seeking) return
const pct = getSeekPct(clientX)
if (pct === null) return
setLocalProgress(pct * (state?.duration ?? 0))
}, [seeking, getSeekPct, state?.duration])
const handleSeekEnd = useCallback((clientX: number) => {
if (!seeking) return
const pct = getSeekPct(clientX)
if (pct !== null && state) {
const seekTo = pct * state.duration
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
setLocalProgress(seekTo)
cmd(id, 'seek', seekTo)
}
setSeeking(false)
}, [seeking, getSeekPct, state, id])
useEffect(() => {
const onMouseMove = (e: MouseEvent) => handleSeekMove(e.clientX)
const onMouseUp = (e: MouseEvent) => handleSeekEnd(e.clientX)
const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) handleSeekMove(e.touches[0].clientX) }
const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]; if (t) handleSeekEnd(t.clientX) }
if (seeking) {
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('touchmove', onTouchMove, { passive: true })
window.addEventListener('touchend', onTouchEnd)
}
return () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
}, [seeking, handleSeekMove, handleSeekEnd])
if (notFound) return (
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-4">
<div className="w-16 h-16 rounded-full bg-surface2 flex items-center justify-center mb-2">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
<div>
<p className="text-app-text font-display font-bold text-[15px] mb-1">Сессия не найдена</p>
<p className="text-muted text-[12px]">Ссылка устарела или сессия завершена</p>
</div>
</div>
)
if (!state) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
</div>
)
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
const progressPct = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
const queue = state.queue ?? []
const versions = state.versions ?? []
const trackTitle = queue[state.cur_idx]?.title ?? ''
const currentOwner = queue[state.cur_idx]
const isSavedLocally = (v: RemoteVersion) => {
const s = savedVersions[trackTitle]
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
}
const handleSaveVersion = (i: number) => {
const v = versions[i]
if (!v || !trackTitle) return
const alreadySaved = isSavedLocally(v)
const next = { ...savedVersions }
if (alreadySaved) delete next[trackTitle]
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
setSavedVersions(next)
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
cmd(id, 'save_version', i)
}
return (
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<span
className="w-1.5 h-1.5 rounded-full shrink-0 transition-all duration-500"
style={{ background: connected ? '#5cc87a' : '#ff6b6b', boxShadow: connected ? '0 0 5px #5cc87a99' : 'none' }}
/>
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
</div>
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
{(['player', 'queue'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className="px-3 py-1.5 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer flex items-center gap-1"
style={{ background: tab === t ? 'rgba(var(--accent-rgb),0.12)' : 'transparent', color: tab === t ? 'var(--accent)' : '#555' }}
>
{t === 'player' ? 'Плеер' : 'Очередь'}
{t === 'queue' && queue.length > 0 && (
<span className="text-[9px] opacity-60 font-display tabular-nums">{queue.length}</span>
)}
</button>
))}
</div>
</div>
{tab === 'player' && (
<div className="flex flex-col gap-6 items-center flex-1">
{/* Cover */}
<div
className="w-full aspect-square rounded-[24px] overflow-hidden bg-surface2 shrink-0"
style={{ boxShadow: state.cover ? '0 20px 60px rgba(var(--accent-rgb),0.18), 0 8px 20px rgba(0,0,0,0.4)' : '0 8px 20px rgba(0,0,0,0.3)' }}
>
{state.cover ? (
<img src={state.cover} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#222" strokeWidth="1">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
</div>
{/* Track info */}
<div className="w-full">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-display text-[20px] font-extrabold tracking-tight leading-tight truncate">
{state.title || '—'}
</div>
{state.artist && (
<div className="text-[13px] text-muted mt-1 truncate">{state.artist}</div>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{currentOwner && (
<span className="text-[10px] px-2 py-0.5 rounded-[5px] font-medium" style={{ background: currentOwner.color_bg, color: currentOwner.color_text }}>
{currentOwner.owner}
</span>
)}
{state.queue_len > 0 && (
<span className="text-[11px] text-muted font-display tabular-nums">{state.cur_idx + 1} / {state.queue_len}</span>
)}
</div>
</div>
</div>
{/* Progress bar */}
<div className="w-full select-none">
<div
ref={progressBarRef}
className="relative h-4 flex items-center cursor-pointer group"
onMouseDown={(e) => { e.preventDefault(); handleSeekStart(e.clientX) }}
onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }}
>
<div className="absolute inset-y-0 flex items-center w-full">
<div className="relative w-full h-1.5 bg-white/[0.08] rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full transition-none"
style={{ width: `${progressPct}%`, background: 'var(--accent)' }}
/>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow transition-none"
style={{ left: `${progressPct}%`, opacity: seeking ? 1 : 0, transition: seeking ? 'none' : 'opacity 0.15s' }}
/>
</div>
</div>
</div>
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums -mt-0.5">
<span>{formatTime(clampedProgress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between w-full px-2">
<button
onClick={() => cmd(id, 'prev')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
</svg>
</button>
<button
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
className="w-18 h-18 rounded-full flex items-center justify-center active:scale-90 transition-all cursor-pointer hover:brightness-110"
style={{
width: 72,
height: 72,
background: 'var(--accent)',
color: '#0a0a0f',
boxShadow: '0 6px 24px rgba(var(--accent-rgb),0.45)',
}}
>
{state.is_playing ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 3 }}>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
onClick={() => cmd(id, 'next')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12z" /><rect x="16" y="6" width="2" height="12" rx="1" />
</svg>
</button>
</div>
{/* Volume */}
<div className="w-full flex items-center gap-3">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
</svg>
<input
type="range"
min="0"
max="1"
step="0.02"
value={volume}
onChange={(e) => {
const v = parseFloat(e.target.value)
setVolume(v)
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
}}
className="flex-1 cursor-pointer h-1.5"
style={{ accentColor: 'var(--accent)' }}
/>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</div>
{/* Versions */}
{versions.length > 1 && (
<div className="w-full">
<button
onClick={() => setVersionsOpen(v => !v)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-[10px] bg-surface2 border border-white/[0.07] hover:border-white/[0.12] transition-colors cursor-pointer"
>
<span className="text-[12px] font-display font-bold tracking-[0.5px] text-muted">Версии трека</span>
<span className="flex items-center gap-2">
<span className="text-[11px] text-muted opacity-60">{versions.length}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2.5" className={`transition-transform duration-200 ${versionsOpen ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{versionsOpen && (
<div className="mt-1.5 border border-white/[0.07] rounded-[10px] overflow-hidden bg-surface2/50">
{versions.map((v, i) => {
const active = i === state.active_version
const saved = isSavedLocally(v)
return (
<div
key={i}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0"
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
>
<span className="text-[10px] text-muted w-4 text-right shrink-0 font-display tabular-nums">{i + 1}</span>
{v.img ? (
<img src={v.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
<div className="text-[12px] text-app-text truncate">{v.title}</div>
<div className="text-[11px] text-muted mt-px truncate">{v.artist}</div>
</div>
<span className="text-[11px] text-muted shrink-0 font-display tabular-nums">{v.duration}</span>
<button
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: active ? 'var(--accent)' : 'transparent', borderColor: active ? 'var(--accent)' : 'rgba(255,255,255,0.07)' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)}
{tab === 'queue' && (
<div className="flex flex-col gap-3 flex-1">
{/* Add track */}
<div className="flex gap-2">
<input
type="text"
value={addText}
onChange={(e) => setAddText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && addText.trim()) {
cmd(id, 'add', 0, addText.trim())
setAddText('')
}
}}
placeholder="Исполнитель — Название"
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[10px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={() => {
if (!addText.trim()) return
cmd(id, 'add', 0, addText.trim())
setAddText('')
}}
className="shrink-0 w-11 rounded-[10px] flex items-center justify-center bg-accent text-bg font-display font-bold text-[18px] cursor-pointer hover:brightness-110 active:scale-95 transition-all"
style={{ color: '#0a0a0f' }}
>
+
</button>
</div>
{/* Queue list */}
{queue.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center py-16 gap-3">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1.5">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="#2a2a2a" stroke="none" />
<circle cx="3" cy="12" r="1" fill="#2a2a2a" stroke="none" />
<circle cx="3" cy="18" r="1" fill="#2a2a2a" stroke="none" />
</svg>
<p className="text-[13px] text-muted">Очередь пуста</p>
</div>
) : (
<div className="flex flex-col">
{queue.map((item, i) => {
const active = i === state.cur_idx
return (
<div
key={i}
onClick={() => cmd(id, 'goto', i)}
className="flex items-center gap-2.5 px-2 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer rounded-[8px] transition-colors active:bg-surface2"
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
>
{/* Index / playing indicator */}
<div className="w-5 flex items-center justify-center shrink-0">
{active ? (
<div className="flex items-end gap-[1.5px] h-3.5">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted font-display tabular-nums">{i + 1}</span>
)}
</div>
{/* Cover */}
{item.img ? (
<img src={item.img} alt="" className="w-9 h-9 rounded-[7px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-9 h-9 rounded-[7px] bg-surface2 shrink-0 flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
{/* Title + owner */}
<div className="flex-1 min-w-0">
<span className="text-[13px] text-app-text truncate block leading-tight"
style={{ color: active ? 'var(--accent)' : undefined }}
>{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[4px] font-medium inline-block mt-0.5" style={{ background: item.color_bg, color: item.color_text }}>
{item.owner}
</span>
</div>
{/* Remove */}
<button
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</main>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useRef, useState } from 'react' import { useRef, useState, useEffect, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore' import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore' import { useVersionStore } from '@/store/versionStore'
@@ -9,13 +9,31 @@ import AddToPlaylist from '@/components/AddToPlaylist'
import Header from '@/components/Header' import Header from '@/components/Header'
import type { SearchResult } from '@/types' import type { SearchResult } from '@/types'
// Module-level cache: survives navigation, cleared on page refresh
let _cachedQuery = ''
let _cachedResults: SearchResult[] | null = null
const SEARCH_HISTORY_KEY = 'pm_search_history'
const MAX_HISTORY = 8
function getHistory(): string[] {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
}
function pushHistory(q: string) {
const next = [q, ...getHistory().filter(s => s !== q)].slice(0, MAX_HISTORY)
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
return next
}
function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: SearchResult) => void }) { function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: SearchResult) => void }) {
const { isFavorite, toggleFavorite } = useFavoritesStore() const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion } = useVersionStore() const { isSaved, saveVersion, removeVersion } = useVersionStore()
const [playlistOpen, setPlaylistOpen] = useState(false) const [playlistOpen, setPlaylistOpen] = useState(false)
const addBtnRef = useRef<HTMLButtonElement>(null) const addBtnRef = useRef<HTMLButtonElement>(null)
const favorited = isFavorite(result.title) const favKey = result.artist ? `${result.artist}${result.title}` : result.title
const favorited = isFavorite(favKey)
const saved = isSaved(result.title, result) const saved = isSaved(result.title, result)
const hasImg = result.img && !result.img.includes('no-cover') const hasImg = result.img && !result.img.includes('no-cover')
@@ -94,7 +112,7 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
{/* Favorite */} {/* Favorite */}
<button <button
onClick={() => toggleFavorite(result.title)} onClick={() => toggleFavorite(favKey)}
title={favorited ? 'Убрать из избранного' : 'В избранное'} title={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer" className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{ style={{
@@ -122,28 +140,45 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
} }
export default function SearchPage() { export default function SearchPage() {
const [query, setQuery] = useState('') const [query, setQuery] = useState(_cachedQuery)
const [results, setResults] = useState<SearchResult[] | null>(null) const [results, setResults] = useState<SearchResult[] | null>(_cachedResults)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [lastQuery, setLastQuery] = useState('') const [lastQuery, setLastQuery] = useState(_cachedQuery)
const [searchHistory, setSearchHistory] = useState<string[]>([])
const { loadPlaylist } = usePartyStore() const { loadPlaylist } = usePartyStore()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const handleSearch = async (e?: React.FormEvent) => { useEffect(() => { setSearchHistory(getHistory()) }, [])
e?.preventDefault()
const q = query.trim() const runSearch = useCallback(async (q: string) => {
if (!q) return if (!q.trim()) return
setLoading(true) setLoading(true)
setResults(null) setResults(null)
setLastQuery(q) setLastQuery(q)
setSearchHistory(pushHistory(q))
try { try {
const found = await searchTracks(q) const raw = await searchTracks(q)
const seen = new Set<string>()
const found = raw.filter(r => {
const key = `${r.artist}|||${r.title}`
if (seen.has(key)) return false
seen.add(key)
return true
})
setResults(found) setResults(found)
_cachedQuery = q
_cachedResults = found
} catch { } catch {
setResults([]) setResults([])
_cachedResults = null
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [])
const handleSearch = (e?: React.FormEvent) => {
e?.preventDefault()
runSearch(query.trim())
} }
const handlePlay = (r: SearchResult) => { const handlePlay = (r: SearchResult) => {
@@ -202,6 +237,35 @@ export default function SearchPage() {
</button> </button>
</form> </form>
{/* Search history */}
{!loading && results === null && searchHistory.length > 0 && (
<div className="mb-5">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Недавние</span>
<button
onClick={() => { try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {} setSearchHistory([]) }}
className="text-[11px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{searchHistory.map((q) => (
<button
key={q}
onClick={() => { setQuery(q); runSearch(q) }}
className="flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-[9px] bg-surface border border-white/[0.07] text-muted hover:text-app-text hover:border-white/[0.14] transition-all cursor-pointer truncate max-w-[220px]"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0 opacity-50">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" strokeLinecap="round" />
</svg>
{q}
</button>
))}
</div>
</div>
)}
{/* Loading */} {/* Loading */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-14 gap-2.5 text-muted"> <div className="flex items-center justify-center py-14 gap-2.5 text-muted">

View File

@@ -0,0 +1,996 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/store/authStore'
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
import { useBgStore, BG_PRESETS, DEFAULT_FX, type BgMode, type FxConfigs } from '@/store/bgStore'
import { getActiveAccent } from '@/store/themeStore'
import { useOverlayStore, OVERLAY_STYLES, type OverlayDesign, type OverlayStyle, type OverlayPosition } from '@/store/overlayStore'
import { getPalettes, STYLE_CUSTOM_FIELDS } from '@/lib/overlayPalettes'
import { OverlayPreview } from '@/components/OverlayWidget'
import Header from '@/components/Header'
import ColorWheel from '@/components/ColorWheel'
// ── Preview SVGs ─────────────────────────────────────────────────────────────
function OrbsPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-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 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
</svg>
)
}
function ParticlesPreview() {
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
const ln: [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"/>
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
{d.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.7:0.45}/>)}
</svg>
)
}
function AuroraPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
</svg>
)
}
function PulsePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
</svg>
)
}
function StarsPreview() {
const stars: [number,number,number][] = [
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<rect width="120" height="68" fill="url(#p-neb)"/>
{stars.map(([x,y,r],i) => (
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
))}
</svg>
)
}
function RainPreview() {
const drops: [number,number,number][] = [
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
{drops.map(([x,y,len],i) => (
<g key={i}>
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}/>
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
</g>
))}
</svg>
)
}
function RaysPreview() {
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
{rays.map((deg, i) => {
const rad = (deg * Math.PI) / 180
const x2 = 60 + Math.cos(rad) * 90
const y2 = 41 + Math.sin(rad) * 90
return (
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
)
})}
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
</svg>
)
}
function NonePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)
}
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
orbs: <OrbsPreview />,
waves: <WavesPreview />,
particles: <ParticlesPreview />,
aurora: <AuroraPreview />,
pulse: <PulsePreview />,
stars: <StarsPreview />,
rain: <RainPreview />,
rays: <RaysPreview />,
none: <NonePreview />,
}
// ── Per-effect slider definitions ─────────────────────────────────────────────
type SliderDef = { key: string; label: string; min: number; max: number; step: number; fmt: (v: number) => string }
const TRAIL_SLIDER: SliderDef = {
key: 'trail', label: 'Шлейф', min: 0, max: 0.95, step: 0.05,
fmt: v => v < 0.02 ? 'Нет' : Math.round(v * 100) + '%',
}
const FX_SLIDERS: Partial<Record<keyof FxConfigs, SliderDef[]>> = {
orbs: [
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
waves: [
{ key: 'amplitude', label: 'Амплитуда', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
particles: [
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'linkDist', label: 'Связи', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
aurora: [
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
pulse: [
{ key: 'sensitivity', label: 'Чувствительность', min: 0, max: 1.0, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ key: 'ringSpeed', label: 'Скорость колец', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
stars: [
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'twinkle', label: 'Мерцание', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
rain: [
{ key: 'drops', label: 'Количество', min: 5, max: 60, step: 1, fmt: v => String(Math.round(v)) },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
rays: [
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: v => String(Math.round(v)) },
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
}
// ── Chevron icon ──────────────────────────────────────────────────────────────
function Chevron({ open }: { open: boolean }) {
return (
<svg
width="16" height="16" viewBox="0 0 16 16" fill="none"
className="shrink-0 transition-transform duration-200"
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
// ── Overlay style previews ────────────────────────────────────────────────────
function OverlayStylePreview({ style }: { style: OverlayStyle }) {
const W = 160, H = 90
if (style === 'classic') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#0a0a10"/>
<rect x="10" y="60" width="100" height="22" rx="8" fill="rgba(10,10,16,0.85)" stroke="rgba(255,255,255,0.09)" strokeWidth="0.6"/>
<rect x="18" y="67" width="10" height="10" rx="3" fill="rgba(255,255,255,0.08)"/>
<rect x="34" y="68" width="50" height="3.5" rx="1.5" fill="rgba(255,255,255,0.7)"/>
<rect x="34" y="74" width="32" height="2" rx="1" fill="rgba(255,255,255,0.3)"/>
<rect x="102" y="66" width="2" height="3" rx="1" fill="var(--accent)"/>
<rect x="105" y="64" width="2" height="7" rx="1" fill="var(--accent)"/>
<rect x="108" y="67" width="2" height="4" rx="1" fill="var(--accent)"/>
</svg>
)
if (style === 'aero') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#1a2a3a"/>
<rect x="10" y="62" width="110" height="20" rx="12" fill="rgba(160,210,255,0.4)" stroke="rgba(255,255,255,0.7)" strokeWidth="0.8"/>
<rect x="10" y="62" width="110" height="10" rx="12" fill="rgba(255,255,255,0.3)"/>
<circle cx="24" cy="72" r="7" fill="rgba(255,255,255,0.3)" stroke="rgba(255,255,255,0.6)" strokeWidth="0.6"/>
<rect x="37" y="68" width="48" height="3" rx="1.5" fill="rgba(0,40,120,0.8)"/>
<rect x="37" y="73" width="30" height="2" rx="1" fill="rgba(0,60,160,0.5)"/>
</svg>
)
if (style === 'retro') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#0a0400"/>
<rect x="10" y="55" width="120" height="28" fill="rgba(12,5,0,0.95)" stroke="#b06828" strokeWidth="1.2"/>
<rect x="10" y="55" width="120" height="10" fill="rgba(160,70,10,0.4)"/>
<text x="15" y="63" fontSize="6" fill="#e08030" fontFamily="monospace"> NOW PLAYING</text>
<text x="108" y="63" fontSize="6" fill="#f09040" fontFamily="monospace"> REC</text>
<rect x="15" y="70" width="90" height="3.5" rx="0" fill="rgba(248,208,144,0.8)" fontFamily="monospace"/>
<rect x="15" y="76" width="55" height="2.5" rx="0" fill="rgba(154,96,32,0.7)"/>
<text x="112" y="80" fontSize="5" fill="#7a4810" fontFamily="monospace">00:42</text>
</svg>
)
if (style === 'neon') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#00000f"/>
<rect x="10" y="54" width="120" height="30" rx="6" fill="rgba(0,0,10,0.9)" stroke="rgba(var(--accent-rgb, 222,156,254),0.65)" strokeWidth="0.8"/>
<rect x="10" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
<rect x="124" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
<text x="16" y="64" fontSize="5" fill="rgba(222,156,254,0.5)" fontFamily="monospace" letterSpacing="2"> STREAM</text>
<rect x="16" y="68" width="80" height="3.5" rx="1" fill="var(--accent)" opacity="0.85"/>
<rect x="16" y="74" width="50" height="2" rx="1" fill="rgba(222,156,254,0.45)"/>
</svg>
)
if (style === 'clean') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#111"/>
<rect x="14" y="58" width="3" height="22" rx="1.5" fill="var(--accent)"/>
<rect x="22" y="60" width="95" height="5" rx="2" fill="rgba(255,255,255,0.85)"/>
<rect x="22" y="68" width="60" height="3" rx="1.5" fill="rgba(255,255,255,0.4)"/>
</svg>
)
if (style === 'y2k') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#8090a0"/>
<rect x="15" y="48" width="120" height="36" rx="3" fill="#c8d0e0" stroke="rgba(255,255,255,0.9)" strokeWidth="1"/>
<rect x="15" y="48" width="120" height="12" rx="3" fill="url(#xp)"/>
<defs><linearGradient id="xp" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#1a4090"/><stop offset="50%" stopColor="#4a80d0"/><stop offset="100%" stopColor="#1a4090"/></linearGradient></defs>
<text x="20" y="57" fontSize="6" fill="white" fontFamily="Tahoma,sans-serif" fontWeight="700">🎵 Party Mix Player</text>
<rect x="120" y="50" width="7" height="6" rx="1" fill="#c0c8d8" stroke="rgba(100,120,160,0.8)" strokeWidth="0.5"/>
<text x="121.5" y="55" fontSize="6" fill="#000" fontWeight="700">×</text>
<rect x="20" y="64" width="100" height="8" rx="1" fill="rgba(255,255,255,0.5)" stroke="#8090b0" strokeWidth="0.5"/>
<rect x="20" y="68" width="45" height="4" rx="0.5" fill="#2060b0"/>
<rect x="20" y="75" width="100" height="5" rx="1" fill="rgba(255,255,255,0.4)" stroke="#8090b0" strokeWidth="0.5"/>
</svg>
)
if (style === 'lofi') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`} style={{ transform: 'rotate(-0.5deg)' }}>
<rect width={W} height={H} fill="#1a0e06"/>
<rect x="12" y="52" width="120" height="34" rx="12" fill="rgba(42,26,14,0.92)" stroke="rgba(240,180,100,0.2)" strokeWidth="0.8"/>
<circle cx="34" cy="69" r="14" fill="radial-gradient(#3a2010,#1a0e06)" stroke="rgba(255,180,80,0.3)" strokeWidth="0.8"/>
<circle cx="34" cy="69" r="14" fill="rgba(30,16,6,0.9)"/>
<circle cx="34" cy="69" r="5" fill="rgba(255,160,60,0.4)"/>
<text x="53" y="62" fontSize="5" fill="rgba(240,180,100,0.5)" fontFamily="serif" fontStyle="italic">now playing</text>
<rect x="53" y="65" width="65" height="3.5" rx="1" fill="rgba(253,232,192,0.8)"/>
<rect x="53" y="71" width="42" height="2.5" rx="1" fill="rgba(240,180,100,0.45)"/>
</svg>
)
if (style === 'glam') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#0a0800"/>
<rect x="15" y="48" width="120" height="38" rx="10" fill="rgba(22,14,2,0.96)" stroke="rgba(212,175,55,0.55)" strokeWidth="0.8"/>
<rect x="15" y="48" width="120" height="2" rx="1" fill="url(#gld)"/>
<rect x="15" y="84" width="120" height="2" rx="1" fill="url(#gld)"/>
<defs><linearGradient id="gld" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="transparent"/><stop offset="30%" stopColor="rgba(212,175,55,0.9)"/><stop offset="50%" stopColor="#ffd700"/><stop offset="70%" stopColor="rgba(212,175,55,0.9)"/><stop offset="100%" stopColor="transparent"/></linearGradient></defs>
<text x="24" y="56" fontSize="5" fill="rgba(212,175,55,0.5)"></text>
<text x="125" y="56" fontSize="5" fill="rgba(212,175,55,0.5)"></text>
<text x="80" y="63" fontSize="5" fill="rgba(212,175,55,0.45)" textAnchor="middle" letterSpacing="2">NOW PLAYING</text>
<rect x="30" y="65" width="90" height="0.8" fill="rgba(212,175,55,0.3)"/>
<rect x="30" y="69" width="90" height="4" rx="1" fill="rgba(240,200,64,0.8)" textAnchor="middle"/>
<rect x="45" y="75" width="60" height="2.5" rx="1" fill="rgba(200,150,30,0.5)"/>
</svg>
)
// matrix
return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#000800"/>
<rect x="10" y="50" width="130" height="34" rx="3" fill="rgba(0,8,0,0.93)" stroke="rgba(0,255,50,0.35)" strokeWidth="0.7"/>
<text x="16" y="61" fontSize="5" fill="rgba(0,200,40,0.5)" fontFamily="monospace">PARTY_MIX@STREAM:~$</text>
<text x="16" y="70" fontSize="7" fill="#00ff32" fontFamily="monospace" fontWeight="700">&gt; Земфира Хочешь?</text>
<text x="16" y="78" fontSize="5" fill="rgba(0,180,30,0.4)" fontFamily="monospace">[LOADING...]</text>
</svg>
)
}
// ── Page ─────────────────────────────────────────────────────────────────────
type SettingsTab = 'appearance' | 'overlay'
const OVERLAY_DESIGNS: { id: OverlayDesign; name: string; desc: string }[] = [
{ id: 'minimal', name: 'Минимальный', desc: 'Маленькая плашка с EQ и названием' },
{ id: 'card', name: 'Карточка', desc: 'Обложка, название и артист' },
{ id: 'bar', name: 'Широкая', desc: 'Полоса во всю ширину снизу' },
]
const OVERLAY_POSITIONS: { id: OverlayPosition; arrow: string; label: string }[] = [
{ id: 'tl', arrow: '↖', label: 'Лево сверху' },
{ id: 'tr', arrow: '↗', label: 'Право сверху' },
{ id: 'bl', arrow: '↙', label: 'Лево снизу' },
{ id: 'br', arrow: '↘', label: 'Право снизу' },
]
const OVERLAY_FONTS: { id: string; name: string; css: string }[] = [
{ id: '', name: 'Авто', css: 'inherit' },
{ id: 'Inter, sans-serif', name: 'Inter', css: 'Inter, sans-serif' },
{ id: 'Space Grotesk, sans-serif', name: 'Space Grotesk',css: 'Space Grotesk, sans-serif' },
{ id: 'Unbounded, sans-serif', name: 'Unbounded', css: 'Unbounded, sans-serif' },
{ id: 'JetBrains Mono, monospace', name: 'JetBrains Mono', css: 'JetBrains Mono, monospace' },
{ id: 'Playfair Display, serif', name: 'Playfair Display', css: 'Playfair Display, serif' },
]
export default function SettingsPage() {
const { user } = useAuthStore()
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
const { bgMode, fxConfigs, setBg, setFxConfig, resetFx } = useBgStore()
const {
enabled: overlayEnabled, design: overlayDesign, style: overlayStyle,
accentColor: overlayAccentColor, position: overlayPosition,
font: overlayFont, textColor: overlayTextColor,
showCover: overlayShowCover, showEq: overlayShowEq,
setEnabled: setOverlayEnabled, setDesign: setOverlayDesign, setStyle: setOverlayStyle,
setAccentColor: setOverlayAccentColor, setPosition: setOverlayPosition,
setFont: setOverlayFont, setTextColor: setOverlayTextColor,
setShowCover: setOverlayShowCover, setShowEq: setOverlayShowEq,
palette: overlayPalette, setPalette: setOverlayPalette,
customPalettes, setCustomPaletteField,
margin: overlayMargin, scale: overlayScale, opacity: overlayOpacity,
setMargin: setOverlayMargin, setScale: setOverlayScale, setOpacity: setOverlayOpacity,
} = useOverlayStore()
const router = useRouter()
const activeAccent = getActiveAccent(accentIdx, customHex)
const [tab, setTab] = useState<SettingsTab>('appearance')
const [showAccent, setShowAccent] = useState(true)
const [showBg, setShowBg] = useState(true)
const [copied, setCopied] = useState(false)
const [activeColorPicker, setActiveColorPicker] = useState<'accent' | 'text' | null>(null)
const [activeCustomField, setActiveCustomField] = useState<string | null>(null)
const [showOvPreview, setShowOvPreview] = useState(true)
const [showOvStyle, setShowOvStyle] = useState(true)
const [showOvPalette, setShowOvPalette] = useState(true)
const [showOvColors, setShowOvColors] = useState(true)
const [showOvFont, setShowOvFont] = useState(true)
const [showOvLayout, setShowOvLayout] = useState(true)
useEffect(() => {
if (!user) router.replace('/login')
}, [user, router])
if (!user) return null
const overlayUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/overlay/${user.id}`
const copyUrl = () => {
navigator.clipboard.writeText(overlayUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
const activeFxMode = bgMode !== 'none' ? bgMode as keyof FxConfigs : null
const fxSliders = activeFxMode ? FX_SLIDERS[activeFxMode] ?? null : null
return (
<main className="max-w-app mx-auto relative z-10">
<Header />
<div className="animate-fadeUp">
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-4">Настройки</h2>
{/* Tabs */}
<div className="flex gap-1 bg-surface2 rounded-[10px] p-1 mb-4">
{([['appearance', 'Внешний вид'], ['overlay', 'Оверлей']] as [SettingsTab, string][]).map(([id, label]) => (
<button
key={id}
onClick={() => setTab(id)}
className="flex-1 py-2 text-[12px] font-display font-bold rounded-[8px] transition-all cursor-pointer"
style={tab === id
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)' }
: { color: '#555' }
}
>
{label}
</button>
))}
</div>
{/* ── Appearance tab ─────────────────────────────────────────────── */}
{tab === 'appearance' && (
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
Внешний вид
</p>
{/* ── Accent color (collapsible) ─────────────────────────────── */}
<button
onClick={() => setShowAccent(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Акцентный цвет</span>
<Chevron open={showAccent} />
</button>
{showAccent && (
<div className="mb-4">
<div className="flex flex-wrap gap-2 mb-3">
{ACCENT_PRESETS.map((preset, i) => (
<button
key={i}
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.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', 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 7px ${preset.accent}90` : 'none' }}/>
{preset.name}
</button>
))}
<button
onClick={() => setCustom(customHex)}
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 === -1
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
style={{
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
}}/>
Свой
</button>
</div>
{accentIdx === -1 && (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={customHex} onChange={setCustom} />
</div>
)}
</div>
)}
{/* ── Live background (collapsible) ─────────────────────────── */}
<button
onClick={() => setShowBg(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Живой фон</span>
<Chevron open={showBg} />
</button>
{showBg && (
<div>
<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>
{/* Per-effect config panel */}
{activeFxMode && fxSliders && (
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
Настройки эффекта
</p>
<button
onClick={() => resetFx(activeFxMode)}
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
>
Сбросить
</button>
</div>
<div className="flex flex-col gap-4">
{fxSliders.map(({ key, label, min, max, step, fmt }) => {
const val = (fxConfigs[activeFxMode] as unknown as Record<string, number>)[key] ?? (DEFAULT_FX[activeFxMode] as unknown as Record<string, number>)[key]
return (
<div key={key}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[12px] text-app-text/70">{label}</span>
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
{fmt(val)}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={val}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(e) => setFxConfig(activeFxMode, { [key]: Number(e.target.value) } as any)}
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
style={{ accentColor: 'var(--accent)' }}
/>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</section>
)}
{/* ── Overlay tab ────────────────────────────────────────────────── */}
{tab === 'overlay' && (
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3 flex flex-col gap-5">
{/* ── Header: enable toggle + URL ──────────────────────────────── */}
<div className="flex items-center gap-3">
<button
onClick={() => setOverlayEnabled(!overlayEnabled)}
className="relative w-11 h-6 rounded-full transition-all duration-200 cursor-pointer shrink-0"
style={{ background: overlayEnabled ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all duration-200"
style={{ left: overlayEnabled ? 'calc(100% - 22px)' : '2px' }}
/>
</button>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-app-text">Оверлей для стрима</p>
<p className="text-[10px] font-mono text-muted/50 truncate mt-0.5">{overlayUrl}</p>
</div>
<button
onClick={copyUrl}
title="Скопировать URL"
className="shrink-0 px-3 py-1.5 rounded-[8px] text-[11px] font-display font-bold cursor-pointer transition-all border"
style={copied
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.3)' }
: { background: 'transparent', color: '#666', borderColor: 'rgba(255,255,255,0.08)' }
}
>
{copied ? '✓' : 'URL'}
</button>
</div>
{/* ── Live preview ─────────────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvPreview(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Превью</span>
<Chevron open={showOvPreview} />
</button>
{showOvPreview && <OverlayPreview />}
</div>
{/* ── Style picker ─────────────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvStyle(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Стиль</span>
<Chevron open={showOvStyle} />
</button>
{showOvStyle && <div className="grid grid-cols-3 gap-2">
{(Object.entries(OVERLAY_STYLES) as [OverlayStyle, typeof OVERLAY_STYLES[OverlayStyle]][]).map(([id, { name, desc }]) => {
const active = overlayStyle === id
return (
<button
key={id}
onClick={() => { setOverlayStyle(id); setOverlayPalette('default'); setActiveCustomField(null) }}
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 bg-black overflow-hidden">
<OverlayStylePreview style={id} />
</div>
<div
className="px-2 py-1.5"
style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}
>
<p className="text-[11px] font-display font-bold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>{name}</p>
<p className="text-[9px] text-muted mt-0.5 truncate">{desc}</p>
</div>
</button>
)
})}
</div>}
</div>
{/* ── Palette picker ───────────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvPalette(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Палитра</span>
<Chevron open={showOvPalette} />
</button>
{showOvPalette && <>
<div className="grid grid-cols-2 gap-2 mb-2">
{getPalettes(overlayStyle).map((pal) => {
const active = overlayPalette === pal.id
return (
<button
key={pal.id}
onClick={() => { setOverlayPalette(pal.id); setActiveCustomField(null) }}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
style={active
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="flex gap-1 shrink-0">
{pal.swatches.slice(0, 3).map((color, i) => (
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/10"
style={{ background: color.startsWith('var(') ? 'var(--accent)' : color }} />
))}
</div>
<span className="text-[12px] font-display font-semibold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
{pal.name}
</span>
</button>
)
})}
{/* Custom palette button */}
{(() => {
const active = overlayPalette === 'custom'
const cp = customPalettes[overlayStyle] ?? {}
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
const swatchColors = fields.slice(0, 3).map(f => cp[f.key] ?? f.default)
return (
<button
onClick={() => {
setOverlayPalette('custom')
if (!activeCustomField) setActiveCustomField(fields[0]?.key ?? null)
}}
className="col-span-2 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
style={active
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="flex gap-1 shrink-0">
{swatchColors.map((color, i) => (
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/20" style={{ background: color }} />
))}
</div>
<span className="text-[12px] font-display font-semibold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
Свой
</span>
<span className="ml-auto text-[11px]" style={{ color: active ? 'var(--accent)' : '#555' }}></span>
</button>
)
})()}
</div>
{/* Custom palette editor */}
{overlayPalette === 'custom' && (() => {
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
const cp = customPalettes[overlayStyle] ?? {}
const currentField = activeCustomField ?? fields[0]?.key ?? null
const currentValue = currentField
? (cp[currentField] ?? (fields.find(f => f.key === currentField)?.default ?? '#ffffff'))
: '#ffffff'
return (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<div className="flex gap-1.5 flex-wrap mb-4">
{fields.map((f) => {
const val = cp[f.key] ?? f.default
const isActive = currentField === f.key
return (
<button
key={f.key}
onClick={() => setActiveCustomField(f.key)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[7px] border text-[11px] font-display font-semibold transition-all cursor-pointer"
style={isActive
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.4)', color: 'var(--accent)' }
: { background: 'rgba(255,255,255,0.03)', borderColor: 'rgba(255,255,255,0.07)', color: '#888' }
}
>
<span className="w-3 h-3 rounded-full border border-white/15 shrink-0" style={{ background: val }} />
{f.label}
</button>
)
})}
</div>
{currentField && (
<ColorWheel
value={currentValue}
onChange={(hex) => setCustomPaletteField(overlayStyle, currentField, hex)}
/>
)}
</div>
)
})()}
</>}
</div>
{/* ── Colors: accent + text ─────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvColors(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Цвета</span>
<Chevron open={showOvColors} />
</button>
{showOvColors && <>
<div className="flex gap-2 mb-2">
{/* Accent color */}
<button
onClick={() => setActiveColorPicker(p => p === 'accent' ? null : 'accent')}
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
style={activeColorPicker === 'accent'
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayAccentColor }} />
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'accent' ? 'var(--accent)' : '#bbb' }}>
Акцент
</span>
<Chevron open={activeColorPicker === 'accent'} />
</button>
{/* Text color */}
<button
onClick={() => setActiveColorPicker(p => p === 'text' ? null : 'text')}
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
style={activeColorPicker === 'text'
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
{overlayTextColor
? <span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayTextColor }} />
: <span className="w-4 h-4 rounded-full border border-white/15 shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
<span className="text-[7px] text-muted">A</span>
</span>
}
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'text' ? 'var(--accent)' : '#bbb' }}>
Текст
</span>
{!overlayTextColor && <span className="ml-auto text-[10px] text-muted/50 font-mono">авто</span>}
<Chevron open={activeColorPicker === 'text'} />
</button>
</div>
{activeColorPicker === 'accent' && (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={overlayAccentColor} onChange={setOverlayAccentColor} />
</div>
)}
{activeColorPicker === 'text' && (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={overlayTextColor || '#ffffff'} onChange={setOverlayTextColor} />
{overlayTextColor && (
<button
onClick={() => setOverlayTextColor('')}
className="mt-3 text-[11px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
>
Авто (по стилю)
</button>
)}
</div>
)}
</>}
</div>
{/* ── Font: horizontal chips ───────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvFont(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Шрифт</span>
<Chevron open={showOvFont} />
</button>
{showOvFont && <div className="flex gap-1.5 overflow-x-auto pb-0.5 -mx-1 px-1">
{OVERLAY_FONTS.map(({ id, name, css }) => {
const active = overlayFont === id
return (
<button
key={id}
onClick={() => setOverlayFont(id)}
className="flex-none flex flex-col items-center gap-1 px-3 pt-2 pb-2 rounded-[10px] border transition-all duration-150 cursor-pointer min-w-[70px]"
style={active
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span
className="text-[15px] leading-none"
style={{ fontFamily: css, color: active ? 'var(--accent)' : '#aaa', fontWeight: active ? 700 : 400 }}
>
Ag
</span>
<span
className="text-[9px] font-display font-semibold truncate max-w-[64px]"
style={{ color: active ? 'var(--accent)' : '#555' }}
>
{name}
</span>
</button>
)
})}
</div>}
</div>
{/* ── Layout: position + cover/eq ──────────────────────────────── */}
<div>
<button
onClick={() => setShowOvLayout(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Макет</span>
<Chevron open={showOvLayout} />
</button>
{showOvLayout && <div className="flex items-start gap-6">
{/* Position grid */}
<div>
<p className="text-[10px] text-muted mb-2">Позиция</p>
<div className="grid grid-cols-2 gap-1 w-[72px]">
{OVERLAY_POSITIONS.map(({ id, arrow, label }) => {
const active = overlayPosition === id
return (
<button
key={id}
title={label}
onClick={() => setOverlayPosition(id)}
className="flex items-center justify-center h-8 rounded-[7px] text-base transition-all duration-150 cursor-pointer border"
style={active
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.03)', color: '#555', borderColor: 'rgba(255,255,255,0.07)' }
}
>
{arrow}
</button>
)
})}
</div>
</div>
{/* Toggles */}
<div className="flex-1 flex flex-col gap-3 pt-[22px]">
{([
{ label: 'Обложка', val: overlayShowCover, set: setOverlayShowCover },
{ label: 'EQ-анимация', val: overlayShowEq, set: setOverlayShowEq },
] as const).map(({ label, val, set }) => (
<div key={label} className="flex items-center justify-between">
<span className="text-[12px] text-app-text/70">{label}</span>
<button
onClick={() => set(!val)}
className="relative w-10 h-5 rounded-full transition-all duration-200 cursor-pointer shrink-0"
style={{ background: val ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
>
<span
className="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200"
style={{ left: val ? 'calc(100% - 18px)' : '2px' }}
/>
</button>
</div>
))}
</div>
</div>}
{/* Sliders: margin / scale / opacity */}
{showOvLayout && (
<div className="flex flex-col gap-4 mt-4">
{([
{ label: 'Отступ', value: overlayMargin, set: setOverlayMargin, min: 8, max: 64, step: 2, fmt: (v: number) => `${v}px` },
{ label: 'Масштаб', value: overlayScale, set: setOverlayScale, min: 0.5, max: 2, step: 0.05, fmt: (v: number) => `${v.toFixed(2)}×` },
{ label: 'Прозрачность', value: overlayOpacity, set: setOverlayOpacity, min: 0.1, max: 1, step: 0.05, fmt: (v: number) => `${Math.round(v * 100)}%` },
] as const).map(({ label, value, set, min, max, step, fmt }) => (
<div key={label}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[12px] text-app-text/70">{label}</span>
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>{fmt(value)}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => set(Number(e.target.value))}
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
style={{ accentColor: 'var(--accent)' }}
/>
</div>
))}
</div>
)}
</div>
{/* ── Note ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 text-[10px] text-muted/50">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
В OBS: источник «Браузер» вставь URL. CSS: <code className="font-mono">body {'{ background: transparent !important; }'}</code>
</div>
</section>
)}
</div>
</main>
)
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { getPublicPlaylists } from '@/lib/authApi'
import type { PublicPlaylist, PlaylistTrack } from '@/types'
import Header from '@/components/Header'
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
function tagColor(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
}
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
return (
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
<div className="flex flex-col gap-0.5">
{tracks.map((track, i) => (
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
</div>
))}
</div>
</div>
)
}
function PlaylistCard({
pl,
onPlay,
isLaunched,
}: {
pl: PublicPlaylist
onPlay: () => void
isLaunched: boolean
}) {
const [expanded, setExpanded] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
<div className="flex items-center gap-3 px-4 py-3.5">
<div
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{pl.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
<span className="text-muted/30 text-[11px]">·</span>
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
{tags.map(tag => (
<span
key={tag}
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
>
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{trackCount > 0 && (
<button
onClick={() => setExpanded(v => !v)}
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 duration-150 cursor-pointer"
title={expanded ? 'Скрыть треки' : 'Показать треки'}
>
<svg
width="11" height="11" viewBox="0 0 12 12" fill="none"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
onClick={onPlay}
disabled={!trackCount}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
}}
>
{isLaunched ? '▶ Играет' : '▶ Play'}
</button>
</div>
</div>
{expanded && pl.tracks && pl.tracks.length > 0 && (
<TrackList tracks={pl.tracks} />
)}
</div>
)
}
export default function CommunityPage() {
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
const [loading, setLoading] = useState(true)
const [launched, setLaunched] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [activeTag, setActiveTag] = useState<string | null>(null)
const { loadPlaylist } = usePartyStore()
useEffect(() => {
getPublicPlaylists()
.then(setPlaylists)
.catch(() => setPlaylists([]))
.finally(() => setLoading(false))
}, [])
const handlePlay = (pl: PublicPlaylist) => {
const tracks = pl.tracks?.map(t => t.title) ?? []
if (!tracks.length) return
loadPlaylist(tracks)
setLaunched(pl.id)
setTimeout(() => setLaunched(null), 2500)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
const filtered = playlists.filter(pl => {
const q = search.toLowerCase().trim()
const matchesSearch = !q
|| pl.name.toLowerCase().includes(q)
|| pl.username.toLowerCase().includes(q)
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
const matchesTag = !activeTag || (pl.tags ?? []).includes(activeTag)
return matchesSearch && matchesTag
})
return (
<main className="max-w-app mx-auto">
<Header />
<div className="mb-5">
<div className="flex items-baseline gap-2.5">
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
{!loading && (
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
)}
</div>
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
</div>
{!loading && playlists.length > 0 && (
<div className="mb-4 flex flex-col gap-2.5">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по названию, автору или тегу..."
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{allTags.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
{allTags.map(tag => (
<button
key={tag}
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
style={activeTag === tag
? { background: tagColor(tag), color: '#0a0a0f' }
: { background: `${tagColor(tag)}15`, color: tagColor(tag), border: `1px solid ${tagColor(tag)}35` }
}
>
{tag}
</button>
))}
</div>
)}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
Загрузка...
</div>
) : !filtered.length ? (
<div className="text-center py-14 text-muted">
<div className="text-4xl mb-3 opacity-20">🎵</div>
<p className="text-[13px] font-medium">
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
</p>
<p className="text-[12px] mt-1.5 opacity-50">
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
{filtered.map(pl => (
<PlaylistCard
key={pl.id}
pl={pl}
onPlay={() => handlePlay(pl)}
isLaunched={launched === pl.id}
/>
))}
</div>
)}
</main>
)
}

View File

@@ -4,8 +4,8 @@
:root { :root {
--border: rgba(255, 255, 255, 0.07); --border: rgba(255, 255, 255, 0.07);
--accent: #c8ff00; --accent: #de9cfe;
--accent-rgb: 200,255,0; --accent-rgb: 222,156,254;
} }
*, *,

View File

@@ -1,9 +1,5 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { Syne, DM_Sans } from 'next/font/google' import { Syne, DM_Sans } from 'next/font/google'
import AuthHydrator from '@/components/AuthHydrator'
import AudioBackground from '@/components/AudioBackground'
import GlobalPlayer from '@/components/GlobalPlayer'
import ThemeApplier from '@/components/ThemeApplier'
import './globals.css' import './globals.css'
const syne = Syne({ const syne = Syne({
@@ -29,17 +25,16 @@ export const viewport: Viewport = {
maximumScale: 1, maximumScale: 1,
} }
const accentInitScript = `(function(){try{var P=[['#de9cfe','222,156,254'],['#c8ff00','200,255,0'],['#00D4FF','0,212,255'],['#FF2D78','255,45,120'],['#A855F7','168,85,247'],['#FF6B35','255,107,53'],['#00FFB2','0,255,178']];var idx=parseInt(localStorage.getItem('pm_accent')||'0',10);var a,r;if(idx===-1){a=localStorage.getItem('pm_accent_custom')||'#de9cfe';var h=a.replace('#','');r=parseInt(h.slice(0,2),16)+','+parseInt(h.slice(2,4),16)+','+parseInt(h.slice(4,6),16);}else{var p=P[idx]||P[0];a=p[0];r=p[1];}document.documentElement.style.setProperty('--accent',a);document.documentElement.style.setProperty('--accent-rgb',r);}catch(e){}})();`
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="ru" className={`${syne.variable} ${dmSans.variable}`}> <html lang="ru" className={`${syne.variable} ${dmSans.variable}`}>
<body className="font-sans bg-bg text-app-text min-h-screen pb-[72px] px-4 pt-5 sm:px-4"> <head>
<ThemeApplier /> <script dangerouslySetInnerHTML={{ __html: accentInitScript }} />
<AudioBackground /> </head>
<div className="relative" style={{ zIndex: 1 }}> <body className="font-sans text-app-text">
<AuthHydrator /> {children}
{children}
</div>
<GlobalPlayer />
</body> </body>
</html> </html>
) )

View File

@@ -0,0 +1,207 @@
'use client'
import { use, useEffect, useRef, useState } from 'react'
import { getPalette, buildCustomPalette } from '@/lib/overlayPalettes'
import { OVERLAY_STYLE_MAP, useLocalProgress, type OverlayWidgetState } from '@/components/OverlayWidget'
import type { OverlayStyle } from '@/store/overlayStore'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const GFONTS = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;700&family=Unbounded:wght@700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap'
type OverlayState = OverlayWidgetState
const ORIGINS: Record<string, string> = {
bl: 'bottom left', br: 'bottom right', tl: 'top left', tr: 'top right',
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function OverlayPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params)
const [display, setDisplay] = useState<OverlayState | null>(null)
const [notFound, setNotFound] = useState(false)
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevTitle = useRef('')
// Transparent background
useEffect(() => {
const html = document.documentElement
const body = document.body
html.style.cssText = 'background:transparent!important;height:100%;'
body.style.cssText = 'background:transparent!important;min-height:0;height:100%;padding:0;margin:0;overflow:hidden;'
return () => { html.style.cssText = ''; body.style.cssText = '' }
}, [])
// Load Google Fonts
useEffect(() => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = GFONTS
document.head.appendChild(link)
return () => { if (document.head.contains(link)) document.head.removeChild(link) }
}, [])
// Apply accent color
useEffect(() => {
const hex = display?.accent_color
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
document.documentElement.style.setProperty('--accent', hex)
document.documentElement.style.setProperty('--accent-rgb', `${r},${g},${b}`)
}, [display?.accent_color])
// Apply position + scale + opacity via CSS vars
useEffect(() => {
const pos = display?.position || 'bl'
const margin = display?.margin ?? 24
const scale = display?.scale ?? 1
const opacity = display?.opacity ?? 1
const isT = pos[0] === 't', isR = pos[1] === 'r'
const h = document.documentElement
const m = `${margin}px`
h.style.setProperty('--ov-b', isT ? 'auto' : m)
h.style.setProperty('--ov-t', isT ? m : 'auto')
h.style.setProperty('--ov-l', isR ? 'auto' : m)
h.style.setProperty('--ov-r', isR ? m : 'auto')
h.style.setProperty('--ov-scale', String(scale))
h.style.setProperty('--ov-origin', ORIGINS[pos] ?? 'bottom left')
h.style.opacity = String(opacity)
}, [display?.position, display?.margin, display?.scale, display?.opacity])
// Apply font to body
useEffect(() => {
document.body.style.fontFamily = display?.font || ''
}, [display?.font])
// Track-change animation: briefly fade out on new track
useEffect(() => {
if (!display?.title) return
if (display.title === prevTitle.current) return
prevTitle.current = display.title
const html = document.documentElement
html.style.transition = 'opacity 0.18s'
html.style.opacity = '0'
const t = setTimeout(() => {
html.style.opacity = String(display.opacity ?? 1)
}, 180)
return () => clearTimeout(t)
}, [display?.title, display?.opacity])
// SSE connection with polling fallback
useEffect(() => {
let active = true
const clearHide = () => {
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null }
}
const scheduleHide = (ms: number) => {
if (hideTimer.current) return
hideTimer.current = setTimeout(() => {
if (active) setDisplay(null)
hideTimer.current = null
}, ms)
}
const applyState = (data: OverlayState) => {
if (!active) return
setNotFound(false)
if (!data.enabled) { scheduleHide(500); return }
clearHide()
setDisplay(prev => {
const title = data.title || prev?.title || ''
if (!title) return null
return {
...data,
title,
artist: data.title ? data.artist : (prev?.artist ?? ''),
cover: data.title ? data.cover : (prev?.cover ?? ''),
}
})
}
// Try SSE first
let es: EventSource | null = null
let pollIv: ReturnType<typeof setInterval> | null = null
let sseOk = false
const startPolling = () => {
if (pollIv) return
const poll = async () => {
try {
const res = await fetch(`${API_URL}/api/overlay/${token}/state`)
if (res.status === 404) { setNotFound(true); return }
if (!res.ok) return
const data: OverlayState = await res.json()
applyState(data)
} catch {}
}
poll()
pollIv = setInterval(poll, 2500)
}
try {
es = new EventSource(`${API_URL}/api/overlay/${token}/stream`)
es.onmessage = (e) => {
sseOk = true
const data: OverlayState = JSON.parse(e.data)
applyState(data)
}
es.addEventListener('notfound', () => { setNotFound(true) })
es.onerror = () => {
if (!sseOk) {
// SSE failed before first message → fall back to polling
es?.close()
es = null
startPolling()
}
}
} catch {
startPolling()
}
return () => {
active = false
es?.close()
if (pollIv) clearInterval(pollIv)
clearHide()
}
}, [token])
if (notFound) return (
<div style={{ position: 'fixed', bottom: 20, left: 20, color: 'rgba(255,255,255,0.25)', fontSize: 11, fontFamily: 'monospace' }}>
overlay not found
</div>
)
if (!display) return null
const style = (display.style ?? 'classic') as OverlayStyle
const render = OVERLAY_STYLE_MAP[style] ?? OVERLAY_STYLE_MAP.classic
const palId = display.palette ?? 'default'
const pal = palId === 'custom'
? buildCustomPalette(style, {
bg: display.custom_bg,
text: display.custom_text,
text2: display.custom_text2,
chroma: display.custom_chroma,
titleBg: display.custom_title_bg,
bodyBg: display.custom_body_bg,
})
: getPalette(style, palId)
return <OverlayRenderer display={display} render={render} pal={pal} />
}
// Separate component so hooks (useLocalProgress) run after display is confirmed non-null
function OverlayRenderer({
display, render, pal,
}: {
display: OverlayState
render: (s: OverlayWidgetState, pal: ReturnType<typeof getPalette>) => React.ReactNode
pal: ReturnType<typeof getPalette>
}) {
const progress = useLocalProgress(display.progress, display.duration, display.is_playing, display.updated_at)
const state = { ...display, progress }
return <>{render(state, pal)}</>
}

View File

@@ -1,164 +0,0 @@
import Link from 'next/link'
const EQ_BARS = [
{ h: 35, d: 0 },
{ h: 72, d: 0.12 },
{ h: 50, d: 0.23 },
{ h: 90, d: 0.06 },
{ h: 55, d: 0.17 },
{ h: 82, d: 0.28 },
{ h: 44, d: 0.09 },
{ h: 96, d: 0.20 },
{ h: 60, d: 0.14 },
{ h: 76, d: 0.03 },
{ h: 40, d: 0.25 },
{ h: 85, d: 0.18 },
{ h: 52, d: 0.07 },
{ h: 67, d: 0.30 },
{ h: 30, d: 0.11 },
{ h: 78, d: 0.22 },
{ h: 48, d: 0.16 },
{ h: 88, d: 0.04 },
{ h: 62, d: 0.26 },
{ h: 38, d: 0.19 },
]
const FEATURES = [
{
icon: '🎵',
title: 'Совместная очередь',
desc: 'Каждый гость добавляет свои треки — никто не обделён эфиром',
},
{
icon: '🎲',
title: 'Умный шаффл',
desc: 'По очереди или случайно — два режима миксовки плейлиста',
},
{
icon: '🔍',
title: 'Поиск версий',
desc: 'Автоматически находит нужную версию трека',
},
{
icon: '📋',
title: 'Плейлисты',
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
},
]
export default function LandingPage() {
return (
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
{/* ── Nav ── */}
<nav className="flex items-center justify-between py-3 mb-2">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<span className="font-display font-extrabold text-lg tracking-tight">
Party<span className="text-accent">Mix</span>
</span>
</div>
<div className="flex items-center gap-2">
<Link
href="/login"
className="text-[13px] font-sans text-muted hover:text-app-text transition-colors duration-150 px-2 py-1"
>
Войти
</Link>
<Link
href="/register"
className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all duration-150"
>
Регистрация
</Link>
</div>
</nav>
{/* ── Hero ── */}
<section className="flex-1 flex flex-col items-center justify-center text-center py-12 relative">
{/* Ambient glow behind EQ */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[200px] pointer-events-none" aria-hidden="true">
<div className="absolute inset-0 bg-accent/[0.04] rounded-full blur-3xl" />
</div>
{/* EQ visualization */}
<div className="relative flex items-end gap-[4px] mb-8 h-20" aria-hidden="true">
{EQ_BARS.map(({ h, d }, i) => (
<div
key={i}
className="eq-bar"
style={{
height: `${h}%`,
animationDelay: `${d}s`,
animationDuration: `${0.6 + d * 0.8}s`,
width: '3px',
}}
/>
))}
</div>
{/* Title */}
<h1 className="font-display font-extrabold leading-none tracking-tight mb-5 text-[72px] sm:text-[104px]">
Party<span className="text-accent">Mix</span>
</h1>
{/* Tagline */}
<p className="font-sans text-base sm:text-lg text-muted max-w-[320px] mb-10 leading-relaxed">
Совместные плейлисты для вечеринок.
<br />
Каждый гость&nbsp; часть музыки.
</p>
{/* CTA buttons */}
<div className="flex items-center gap-3 flex-wrap justify-center">
<Link
href="/app"
className="px-8 py-3 bg-accent text-bg font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all duration-150 shadow-[0_0_24px_rgba(var(--accent-rgb),0.25)]"
>
Начать вечеринку
</Link>
<Link
href="/login"
className="px-8 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all duration-150"
>
Войти
</Link>
</div>
</section>
{/* ── Divider ── */}
<div className="border-t border-white/[0.05] mb-8" />
{/* ── Features ── */}
<section className="pb-10">
<p className="text-[11px] font-display font-semibold tracking-[1.5px] uppercase text-muted mb-4">
Возможности
</p>
<div className="grid grid-cols-2 gap-3">
{FEATURES.map(({ icon, title, desc }) => (
<div
key={title}
className="group bg-surface border border-white/[0.07] rounded-app p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
>
<span className="text-lg mb-2.5 block">{icon}</span>
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
<p className="text-[12px] text-muted font-sans leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* ── Footer ── */}
<footer className="pb-4 text-center">
</footer>
</div>
)
}

View File

@@ -1,417 +0,0 @@
'use client'
import { use, useEffect, useRef, useState } from 'react'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
interface RemoteQueueItem {
title: string
owner: string
color_bg: string
color_text: string
img?: string
}
interface RemoteVersion {
title: string
artist: string
duration: string
img?: string
}
interface RemoteState {
title: string
artist: string
cover: string
is_playing: boolean
volume: number
progress: number
duration: number
queue_len: number
cur_idx: number
queue: RemoteQueueItem[]
versions: RemoteVersion[]
active_version: number
}
function formatTime(s: number) {
if (!s || isNaN(s)) return '0:00'
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
}
async function cmd(id: string, c: string, value?: number, text?: string) {
await fetch(`${API_URL}/api/remote/${id}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
}).catch(() => {})
}
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [state, setState] = useState<RemoteState | null>(null)
const [notFound, setNotFound] = useState(false)
const [volume, setVolume] = useState(1)
const [addText, setAddText] = useState('')
const [tab, setTab] = useState<'player' | 'queue'>('player')
const [versionsOpen, setVersionsOpen] = useState(false)
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
const [localProgress, setLocalProgress] = useState(0)
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
useEffect(() => {
try {
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
if (s) setSavedVersions(JSON.parse(s))
} catch {}
}, [])
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
let active = true
const poll = async () => {
try {
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
if (res.status === 404) { setNotFound(true); return }
if (!res.ok) return
const data: RemoteState = await res.json()
if (active) {
setState(data)
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
setLocalProgress(data.progress ?? 0)
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
}
} catch {}
}
poll()
const iv = setInterval(poll, 2000)
return () => { active = false; clearInterval(iv) }
}, [id])
// Local progress interpolation — advance every 250ms when playing
useEffect(() => {
const iv = setInterval(() => {
const { progress, ts, playing } = lastPollRef.current
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
}, 250)
return () => clearInterval(iv)
}, [])
if (notFound) return (
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
<p className="text-muted text-[13px]">Сессия не найдена или истекла</p>
</div>
)
if (!state) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
</div>
)
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
const progress = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
const queue = state.queue ?? []
const versions = state.versions ?? []
const trackTitle = queue[state.cur_idx]?.title ?? ''
const isSavedLocally = (v: RemoteVersion) => {
const s = savedVersions[trackTitle]
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
}
const handleSaveVersion = (i: number) => {
const v = versions[i]
if (!v || !trackTitle) return
const alreadySaved = isSavedLocally(v)
const next = { ...savedVersions }
if (alreadySaved) delete next[trackTitle]
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
setSavedVersions(next)
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
cmd(id, 'save_version', i)
}
return (
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-6">
{/* Header + tabs */}
<div className="flex items-center justify-between mb-5">
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
<div className="flex items-center gap-1 bg-surface2 rounded-[8px] p-0.5">
<button
onClick={() => setTab('player')}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer"
style={{ background: tab === 'player' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'player' ? 'var(--accent)' : '#555' }}
>
Плеер
</button>
<button
onClick={() => setTab('queue')}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer flex items-center gap-1"
style={{ background: tab === 'queue' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'queue' ? 'var(--accent)' : '#555' }}
>
Очередь
{queue.length > 0 && <span className="text-[10px] opacity-60">{queue.length}</span>}
</button>
</div>
</div>
{tab === 'player' && (
<div className="flex flex-col gap-7 items-center flex-1">
{/* Cover */}
<div className="w-full aspect-square max-w-[220px] rounded-[20px] overflow-hidden bg-surface2 shadow-2xl">
{state.cover ? (
<img src={state.cover} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
</div>
{/* Track info */}
<div className="w-full text-center px-2">
<div className="font-display text-[18px] font-extrabold tracking-tight truncate leading-tight">
{state.title || '—'}
</div>
{state.artist && (
<div className="text-[13px] text-muted mt-1.5 truncate">{state.artist}</div>
)}
{state.queue_len > 0 && (
<div className="text-[11px] text-muted mt-1 font-display">{state.cur_idx + 1} / {state.queue_len}</div>
)}
</div>
{/* Progress bar */}
<div className="w-full">
<div
className="relative h-1.5 bg-white/[0.07] rounded-full mb-2 cursor-pointer"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const seekTo = pct * state.duration
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
setLocalProgress(seekTo)
cmd(id, 'seek', seekTo)
}}
>
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${progress}%` }} />
</div>
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums">
<span>{formatTime(clampedProgress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-8">
<button
onClick={() => cmd(id, 'prev')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
</svg>
</button>
<button
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
className="w-16 h-16 rounded-full flex items-center justify-center bg-accent text-bg active:scale-95 transition-all cursor-pointer hover:brightness-110"
>
{state.is_playing ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
onClick={() => cmd(id, 'next')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12z"/><rect x="16" y="6" width="2" height="12"/>
</svg>
</button>
</div>
{/* Volume */}
<div className="w-full flex items-center gap-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
</svg>
<input
type="range"
min="0"
max="1"
step="0.02"
value={volume}
onChange={(e) => {
const v = parseFloat(e.target.value)
setVolume(v)
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
}}
className="flex-1 cursor-pointer h-1.5"
style={{ accentColor: 'var(--accent)' }}
/>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</div>
{/* Versions */}
{versions.length > 1 && (
<div className="w-full">
<button
onClick={() => setVersionsOpen(v => !v)}
className="w-full flex items-center justify-between px-3 py-2 rounded-[9px] bg-surface2 border border-white/[0.07] text-[12px] text-muted hover:text-app-text transition-colors cursor-pointer"
>
<span className="font-display font-bold tracking-[0.5px]">Версии трека</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] opacity-60">{versions.length}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={`transition-transform ${versionsOpen ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{versionsOpen && (
<div className="mt-1 border border-white/[0.07] rounded-[9px] overflow-hidden">
{versions.map((v, i) => {
const active = i === state.active_version
const saved = isSavedLocally(v)
return (
<div
key={i}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0 transition-colors"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
>
<span className="text-[11px] text-muted w-4 text-right shrink-0 font-display">{i + 1}</span>
{v.img ? (
<img src={v.img} alt="" className="w-8 h-8 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[5px] bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{v.title}</div>
<div className="text-[11px] text-muted mt-px">{v.artist}</div>
</div>
<span className="text-[11px] text-muted shrink-0 font-display">{v.duration}</span>
<button
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
title={saved ? 'Забыть версию' : 'Запомнить версию'}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${active ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)}
{tab === 'queue' && (
<div className="flex flex-col gap-3 flex-1">
{/* Add track */}
<div className="flex gap-2">
<input
type="text"
value={addText}
onChange={(e) => setAddText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && addText.trim()) {
cmd(id, 'add', 0, addText.trim())
setAddText('')
}
}}
placeholder="Исполнитель — Название"
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={() => {
if (!addText.trim()) return
cmd(id, 'add', 0, addText.trim())
setAddText('')
}}
className="shrink-0 px-3 py-2.5 rounded-[9px] bg-accent text-bg text-[13px] font-display font-bold cursor-pointer hover:brightness-110 active:scale-95 transition-all"
>
+
</button>
</div>
{/* Queue list */}
{queue.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-[13px] text-muted text-center py-10">
Очередь пуста
</div>
) : (
<div className="flex flex-col">
{queue.map((item, i) => {
const active = i === state.cur_idx
return (
<div
key={i}
onClick={() => cmd(id, 'goto', i)}
className="flex items-center gap-2.5 px-1 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer active:bg-surface2 transition-colors rounded-[6px]"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
>
{active ? (
<div className="flex items-end gap-[1.5px] w-3.5 h-3.5 shrink-0 ml-0.5">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-5 text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img src={item.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color_bg, color: item.color_text }}>
{item.owner}
</span>
<button
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</main>
)
}

View File

@@ -1,318 +0,0 @@
'use client'
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, DEFAULT_RAYS, type BgMode } from '@/store/bgStore'
import { getActiveAccent } from '@/store/themeStore'
import Header from '@/components/Header'
import ColorWheel from '@/components/ColorWheel'
// ── Preview SVGs ─────────────────────────────────────────────────────────────
function OrbsPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-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 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
</svg>
)
}
function ParticlesPreview() {
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
const ln: [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"/>
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
{d.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.7:0.45}/>)}
</svg>
)
}
function AuroraPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
</svg>
)
}
function PulsePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
</svg>
)
}
function StarsPreview() {
const stars: [number,number,number][] = [
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<rect width="120" height="68" fill="url(#p-neb)"/>
{stars.map(([x,y,r],i) => (
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
))}
</svg>
)
}
function RainPreview() {
const drops: [number,number,number][] = [
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
{drops.map(([x,y,len],i) => (
<g key={i}>
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}
style={{background: `linear-gradient(to bottom, transparent, var(--accent))`}}/>
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
</g>
))}
</svg>
)
}
function RaysPreview() {
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
{rays.map((deg, i) => {
const rad = (deg * Math.PI) / 180
const x2 = 60 + Math.cos(rad) * 90
const y2 = 41 + Math.sin(rad) * 90
return (
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
)
})}
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
</svg>
)
}
function NonePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)
}
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
orbs: <OrbsPreview />,
waves: <WavesPreview />,
particles: <ParticlesPreview />,
aurora: <AuroraPreview />,
pulse: <PulsePreview />,
stars: <StarsPreview />,
rain: <RainPreview />,
rays: <RaysPreview />,
none: <NonePreview />,
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const { user } = useAuthStore()
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
const { bgMode, setBg, raysConfig, setRaysConfig } = useBgStore()
const router = useRouter()
const activeAccent = getActiveAccent(accentIdx, customHex)
useEffect(() => {
if (!user) router.replace('/login')
}, [user, router])
if (!user) return null
return (
<main className="max-w-app mx-auto relative z-10">
<Header />
<div className="animate-fadeUp">
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-5">Настройки</h2>
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
Внешний вид
</p>
{/* Accent color */}
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
<div className="flex flex-wrap gap-2 mb-4">
{ACCENT_PRESETS.map((preset, i) => (
<button
key={i}
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.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', 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 7px ${preset.accent}90` : 'none' }}/>
{preset.name}
</button>
))}
{/* Custom color button */}
<button
onClick={() => setCustom(customHex)}
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 === -1
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
style={{
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
}}/>
Свой
</button>
</div>
{/* Color wheel — shown when custom is selected */}
{accentIdx === -1 && (
<div className="mb-6 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={customHex} onChange={setCustom} />
</div>
)}
{accentIdx !== -1 && <div className="mb-2" />}
{/* Live background */}
<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>
{/* Rays config — shown only when rays mode is active */}
{bgMode === 'rays' && (
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
Настройки лучей
</p>
<button
onClick={() => setRaysConfig(DEFAULT_RAYS)}
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
>
Сбросить
</button>
</div>
<div className="flex flex-col gap-4">
{([
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: (v: number) => String(Math.round(v)) },
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
] as const).map(({ key, label, min, max, step, fmt }) => (
<div key={key}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[12px] text-app-text/70">{label}</span>
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
{fmt(raysConfig[key])}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={raysConfig[key]}
onChange={(e) => setRaysConfig({ [key]: Number(e.target.value) })}
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
style={{ accentColor: 'var(--accent)' }}
/>
</div>
))}
</div>
</div>
)}
</section>
</div>
</main>
)
}

View File

@@ -1,4 +1,4 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useFavoritesStore } from '@/store/favoritesStore' import { useFavoritesStore } from '@/store/favoritesStore'
@@ -14,16 +14,16 @@ interface Props {
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) { export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
const { isFavorite, toggleFavorite } = useFavoritesStore() const { isFavorite, toggleFavorite } = useFavoritesStore()
const { token } = useAuthStore() const { user } = useAuthStore()
const [playlists, setPlaylists] = useState<Playlist[]>([]) const [playlists, setPlaylists] = useState<Playlist[]>([])
const [added, setAdded] = useState<Record<string, boolean>>({}) const [added, setAdded] = useState<Record<string, boolean>>({})
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const favorited = isFavorite(trackTitle) const favorited = isFavorite(trackTitle)
useEffect(() => { useEffect(() => {
if (!token) return if (!user) return
getPlaylists(token).then(setPlaylists).catch(() => {}) getPlaylists().then(setPlaylists).catch(() => {})
}, [token]) }, [user])
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -37,9 +37,9 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
}, [onClose, anchorRef]) }, [onClose, anchorRef])
const handleAdd = async (playlist: Playlist) => { const handleAdd = async (playlist: Playlist) => {
if (!token || added[playlist.id]) return if (!user || added[playlist.id]) return
try { try {
await addTrackToPlaylist(token, playlist.id, trackTitle) await addTrackToPlaylist(playlist.id, trackTitle)
setAdded(prev => ({ ...prev, [playlist.id]: true })) setAdded(prev => ({ ...prev, [playlist.id]: true }))
} catch {} } catch {}
} }
@@ -74,7 +74,7 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
)} )}
</button> </button>
{token ? ( {user ? (
playlists.length > 0 ? ( playlists.length > 0 ? (
<div className="max-h-[180px] overflow-y-auto"> <div className="max-h-[180px] overflow-y-auto">
{playlists.map(pl => ( {playlists.map(pl => (

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 } type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
export default function AudioBackground() { export default function AudioBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const { bgMode, raysConfig } = useBgStore() const { bgMode, fxConfigs } = useBgStore()
const raysRef = useRef(raysConfig) const fxRef = useRef(fxConfigs)
// Keep rays config in sync without restarting the animation loop // Sync config changes without restarting the animation loop
useEffect(() => { raysRef.current = raysConfig }, [raysConfig]) useEffect(() => { fxRef.current = fxConfigs }, [fxConfigs])
useEffect(() => { useEffect(() => {
if (bgMode === 'none') return if (bgMode === 'none') return
@@ -28,7 +28,7 @@ export default function AudioBackground() {
let rafId: number let rafId: number
let smoothBass = 0 let smoothBass = 0
let smoothMid = 0 let smoothMid = 0
let fastBass = 0 // fast tracker for onset/beat detection let fastBass = 0
let dataBuf: Uint8Array<ArrayBuffer> | null = null let dataBuf: Uint8Array<ArrayBuffer> | null = null
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight } 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' 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 drawOrbs = () => {
const W = canvas.width, H = canvas.height, a = ac() 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 br = Math.sin(t) * 0.04 + Math.cos(t * 0.7) * 0.02
const diag = Math.hypot(W, H), base = diag * 0.62 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 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) 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)`) 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) ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
// pink bottom-right
const r2 = base * (0.70 + smoothMid * 0.40 - br * 0.5) 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) 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)') 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) ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
// purple center — only shows with audio
const c = smoothBass * 0.5 + smoothMid * 0.5 const c = smoothBass * 0.5 + smoothMid * 0.5
if (c > 0.008) { if (c > 0.008) {
const r3 = base * (0.40 + c * 0.35) 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) 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) ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
} }
} }
@@ -90,14 +100,16 @@ export default function AudioBackground() {
// ── WAVES ───────────────────────────────────────────────────────────────── // ── WAVES ─────────────────────────────────────────────────────────────────
const drawWaves = () => { const drawWaves = () => {
const W = canvas.width, H = canvas.height, a = ac() const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 1000 const cfg = fxRef.current.waves
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H) const t = (Date.now() / 1000) * cfg.speed
const amp = cfg.amplitude
clear(W, H, cfg.trail)
const layers = [ const layers = [
{ y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, 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, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' }, { 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, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` }, { 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, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' }, { 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, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` }, { 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) { for (const l of layers) {
const baseY = H * l.y const baseY = H * l.y
@@ -119,8 +131,9 @@ export default function AudioBackground() {
})) }))
const drawParticles = () => { const drawParticles = () => {
const W = canvas.width, H = canvas.height, a = ac() const W = canvas.width, H = canvas.height, a = ac()
ctx.fillStyle = 'rgba(10,10,15,0.2)'; ctx.fillRect(0, 0, W, H) const cfg = fxRef.current.particles
const spd = 1 + smoothBass * 3 clear(W, H, cfg.trail)
const spd = (1 + smoothBass * 3) * cfg.speed
for (const p of PTS) { for (const p of PTS) {
p.x += p.vx * spd; p.y += p.vy * spd 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.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.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() 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 i = 0; i < PTS.length; i++)
for (let j = i + 1; j < PTS.length; j++) { 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 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 drawAurora = () => {
const W = canvas.width, H = canvas.height, a = ac() const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 3500 const cfg = fxRef.current.aurora
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H) const t = (Date.now() / 3500) * cfg.speed
const bri = cfg.brightness
clear(W, H, cfg.trail)
const bands = [ 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.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, c: '140,100,255' }, { 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, c: '255,60,172' }, { 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, c: a }, { 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, c: '140,100,255' }, { 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, c: '255,60,172' }, { 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) { for (const band of bands) {
const cx = band.cx * W, cy = (band.cy + Math.sin(band.ph) * 0.07) * H 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[] = [] const RINGS: Ring[] = []
let prevFast = 0 let lastRingMs = 0
let lastAmbMs = 0
const drawPulse = () => { const drawPulse = () => {
const W = canvas.width, H = canvas.height, a = ac() const W = canvas.width, H = canvas.height, a = ac()
const cfg = fxRef.current.pulse
const cx = W * 0.5, cy = H * 0.5 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 now = Date.now()
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
// Slow ambient rings so mode looks alive without audio // Beat-triggered ring
if (Math.random() < 0.008 && RINGS.length < 3) RINGS.push({ r: 5, alpha: 0.22, speed: 1.5 }) 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--) { for (let i = RINGS.length - 1; i >= 0; i--) {
const ring = RINGS[i] const ring = RINGS[i]
ring.r += ring.speed ring.r += ring.speed
ring.alpha -= 0.008 ring.alpha *= 0.982 // exponential fade — stays bright longer, fades smoothly
if (ring.alpha <= 0 || ring.r > maxR) { RINGS.splice(i, 1); continue } 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 // Main ring
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2) ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${a},${ring.alpha})` ctx.strokeStyle = `rgba(${a},${ring.alpha})`
ctx.lineWidth = 1.5 + ring.alpha * 3.5; ctx.stroke() ctx.lineWidth = lw; ctx.stroke()
// Inner echo // Pink echo
if (ring.r > 40 && ring.alpha > 0.15) { if (ring.r > 30 && ring.alpha > 0.08) {
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.80, 0, Math.PI * 2) ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.85, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.4})` ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.35})`
ctx.lineWidth = 0.8; ctx.stroke() ctx.lineWidth = lw * 0.55; ctx.stroke()
} }
} }
// Persistent center glow // Center dot — pulses with bass, small footprint so it doesn't drown rings
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80 + smoothBass * 120) const dotR = 14 + smoothBass * 55
cg.addColorStop(0, `rgba(${a},${0.08 + smoothBass * 0.14})`); cg.addColorStop(1, `rgba(${a},0)`) const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, dotR)
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H) 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 ───────────────────────────────────────────────────────────────── // ── STARS ─────────────────────────────────────────────────────────────────
@@ -220,22 +255,24 @@ export default function AudioBackground() {
})) }))
const drawStars = () => { const drawStars = () => {
const W = canvas.width, H = canvas.height, a = ac() const W = canvas.width, H = canvas.height, a = ac()
const cfg = fxRef.current.stars
const t = Date.now() / 1000 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) 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) ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H)
for (const s of STARS) { for (const s of STARS) {
const tw = (Math.sin(t * s.sp + s.ph) + 1) * 0.5 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) const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4) * bri
const r = s.r * (1 + smoothBass * tw * 1.0) 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() } 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() 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 ─────────────────────────────────────────── // ── RAIN ──────────────────────────────────────────────────────────────────
const DROPS: Drop[] = Array.from({ length: 30 }, () => ({ const DROPS: Drop[] = Array.from({ length: 60 }, () => ({
x: Math.random(), y: Math.random(), x: Math.random(), y: Math.random(),
speed: Math.random() * 0.0015 + 0.0008, speed: Math.random() * 0.0015 + 0.0008,
len: Math.random() * 0.055 + 0.03, len: Math.random() * 0.055 + 0.03,
@@ -243,9 +280,12 @@ export default function AudioBackground() {
})) }))
const drawRain = () => { const drawRain = () => {
const W = canvas.width, H = canvas.height, a = ac() const W = canvas.width, H = canvas.height, a = ac()
ctx.fillStyle = 'rgba(10,10,15,0.12)'; ctx.fillRect(0, 0, W, H) const cfg = fxRef.current.rain
const spd = 1 + smoothBass * 2.5 const count = Math.round(cfg.drops)
for (const d of 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 d.y += d.speed * spd
if (d.y > 1.08) { d.y = -d.len - 0.02; d.x = Math.random() } 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) 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 drawRays = () => {
const W = canvas.width, H = canvas.height, a = ac() 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 t = (Date.now() / 1000) * cfg.speed * 0.08
const cx = W * 0.5, cy = H * 0.55 const cx = W * 0.5, cy = H * 0.55
const maxR = Math.hypot(W, H) * 1.1 const maxR = Math.hypot(W, H) * 1.1
const br = cfg.brightness const br = cfg.brightness
const sp = cfg.spread 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 const count = cfg.count
// Primary rays — rotate forward
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + t const angle = (i / count) * Math.PI * 2 + t
const isMain = i % 2 === 0 const isMain = i % 2 === 0
const hw = Math.tan((0.055 + smoothBass * 0.035) * sp) * maxR 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 al = ((isMain ? 0.12 : 0.07) + smoothBass * 0.10 + smoothMid * 0.03) * br
const ex = cx + Math.cos(angle) * maxR const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
const ey = cy + Math.sin(angle) * maxR const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
const px = -Math.sin(angle) * hw
const 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() 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) const grad = ctx.createLinearGradient(cx, cy, ex, ey)
grad.addColorStop(0, `rgba(${a},${al * 2.5})`) grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
grad.addColorStop(0.12, `rgba(${a},${al})`) grad.addColorStop(0.12, `rgba(${a},${al})`)
@@ -292,17 +327,14 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill() ctx.fillStyle = grad; ctx.fill()
} }
// Secondary rays — counter-rotate, pink tint
const cnt2 = Math.max(2, Math.floor(count / 2)) const cnt2 = Math.max(2, Math.floor(count / 2))
for (let i = 0; i < cnt2; i++) { for (let i = 0; i < cnt2; i++) {
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
const hw = Math.tan(0.035 * sp) * maxR const hw = Math.tan(0.035 * sp) * maxR
const al = (0.05 + smoothMid * 0.06) * br const al = (0.05 + smoothMid * 0.06) * br
const ex = cx + Math.cos(angle) * maxR const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
const ey = cy + Math.sin(angle) * maxR const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
const px = -Math.sin(angle) * hw
const 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() 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) const grad = ctx.createLinearGradient(cx, cy, ex, ey)
@@ -312,7 +344,6 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill() ctx.fillStyle = grad; ctx.fill()
} }
// Center glow
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 100 + smoothBass * 140) 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)`) 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) ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)

View File

@@ -1,4 +1,4 @@
'use client' 'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react' import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
@@ -9,6 +9,9 @@ import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz' import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState' import { audioState } from '@/lib/audioState'
import AddToPlaylist from '@/components/AddToPlaylist' import AddToPlaylist from '@/components/AddToPlaylist'
import QueuePanel from '@/components/Player/QueuePanel'
import VersionsPanel from '@/components/Player/VersionsPanel'
import { useToastStore } from '@/store/toastStore'
import type { SearchResult } from '@/types' import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080' const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
@@ -25,10 +28,14 @@ function formatTime(s: number) {
} }
export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) { export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } = const {
usePartyStore() queue, curIdx, loadKey, repeatMode,
updateQueueItemImg, setCurrentResults, setCurrentResult, setSearchStatus, searchStatus,
setCurIdx, setRepeatMode, removeFromQueue, addTrackToQueue,
} = usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore() const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore() const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
const showToast = useToastStore((s) => s.show)
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine() const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -43,7 +50,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null) const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
const [playlistOpen, setPlaylistOpen] = useState(false) const [playlistOpen, setPlaylistOpen] = useState(false)
const playlistBtnRef = useRef<HTMLButtonElement>(null) const playlistBtnRef = useRef<HTMLButtonElement>(null)
const dragSrcIdx = useRef<number | null>(null)
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
// Remote control // Remote control
@@ -62,6 +68,9 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map()) const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
const preloadAudioRef = useRef<HTMLAudioElement | null>(null) const preloadAudioRef = useRef<HTMLAudioElement | null>(null)
// Touch swipe → prev/next track
const touchStartX = useRef<number | null>(null)
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying) useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying)
useEffect(() => { useEffect(() => {
@@ -77,6 +86,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
if (!r) return if (!r) return
setActiveResultIdx(resIdx) setActiveResultIdx(resIdx)
activeResultIdxRef.current = resIdx activeResultIdxRef.current = resIdx
setCurrentResult(r)
setAudioMeta({ title: r.title, artist: r.artist }) setAudioMeta({ title: r.title, artist: r.artist })
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img)) if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
const audio = audioRef.current const audio = audioRef.current
@@ -89,14 +99,12 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name) if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name)
}) })
}, },
[audioRef, resumeContext], [audioRef, resumeContext, setCurrentResult],
) )
const playResultRef = useRef(playResult) const playResultRef = useRef(playResult)
useEffect(() => { playResultRef.current = playResult }, [playResult]) useEffect(() => { playResultRef.current = playResult }, [playResult])
// Returns cached results immediately, awaits in-flight request, or starts new one.
// Deduplicates parallel requests for the same title.
const prefetchOrGet = useCallback(async (title: string): Promise<SearchResult[]> => { const prefetchOrGet = useCallback(async (title: string): Promise<SearchResult[]> => {
const cached = prefetchCacheRef.current.get(title) const cached = prefetchCacheRef.current.get(title)
if (cached) return cached if (cached) return cached
@@ -142,6 +150,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
if (!found.length) { if (!found.length) {
setSearchStatus('not-found') setSearchStatus('not-found')
showToast(`Трек не найден: ${track.title}`, 'error')
setTimeout(() => { setTimeout(() => {
if (loadingKeyRef.current !== key) return if (loadingKeyRef.current !== key) return
const s = usePartyStore.getState() const s = usePartyStore.getState()
@@ -167,7 +176,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
playResult(found, startIdx) playResult(found, startIdx)
// Prefetch next 3 tracks; for N+1 also preload audio into hidden element
for (let offset = 1; offset <= 3; offset++) { for (let offset = 1; offset <= 3; offset++) {
const nextTrack = queue[idx + offset] const nextTrack = queue[idx + offset]
if (!nextTrack) continue if (!nextTrack) continue
@@ -176,29 +184,25 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
p.then(nextResults => { p.then(nextResults => {
if (loadingKeyRef.current !== key) return if (loadingKeyRef.current !== key) return
if (!nextResults.length) return if (!nextResults.length) return
const saved = getSavedVersion(nextTrack.title) const sv = getSavedVersion(nextTrack.title)
const si = saved ? nextResults.findIndex(r => r.title === saved.title && r.artist === saved.artist) : -1 const si = sv ? nextResults.findIndex(r => r.title === sv.title && r.artist === sv.artist) : -1
const resIdx = si >= 0 ? si : 0 const resIdx = si >= 0 ? si : 0
const preloadEl = preloadAudioRef.current const preloadEl = preloadAudioRef.current
if (!preloadEl) return if (!preloadEl) return
const url = proxyMp3Url(nextResults[resIdx].mp3) const url = proxyMp3Url(nextResults[resIdx].mp3)
if (preloadEl.src !== url) { if (preloadEl.src !== url) { preloadEl.src = url; preloadEl.load() }
preloadEl.src = url
preloadEl.load()
}
}).catch(() => {}) }).catch(() => {})
} else { } else {
p.catch(() => {}) p.catch(() => {})
} }
} }
// Evict stale cache entries outside the current window
const windowTitles = new Set(queue.slice(Math.max(0, idx - 1), idx + 6).map(t => t.title)) const windowTitles = new Set(queue.slice(Math.max(0, idx - 1), idx + 6).map(t => t.title))
prefetchCacheRef.current.forEach((_, title) => { prefetchCacheRef.current.forEach((_, title) => {
if (!windowTitles.has(title)) prefetchCacheRef.current.delete(title) if (!windowTitles.has(title)) prefetchCacheRef.current.delete(title)
}) })
}, },
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet], [queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet, showToast],
) )
useEffect(() => { useEffect(() => {
@@ -220,8 +224,8 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
audioState.analyser = analyserRef.current audioState.analyser = analyserRef.current
} }
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false } const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false }
const onTimeUpdate = () => setCurrentTime(audio.currentTime) const onTimeUpdate = () => { setCurrentTime(audio.currentTime); audioState.currentTime = audio.currentTime }
const onDuration = () => setDuration(audio.duration || 0) const onDuration = () => { const d = audio.duration || 0; setDuration(d); audioState.duration = d }
const onEnded = () => { const onEnded = () => {
setIsPlaying(false) setIsPlaying(false)
isPlayingRef.current = false isPlayingRef.current = false
@@ -229,15 +233,18 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const r = activeResultsRef.current[activeResultIdxRef.current] const r = activeResultsRef.current[activeResultIdxRef.current]
if (r) onTrackEnd(r) if (r) onTrackEnd(r)
const s = usePartyStore.getState() const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) const repeat = s.repeatMode
if (repeat === 'one') {
s.setCurIdx(s.curIdx)
} else if (repeat === 'all') {
s.setCurIdx(s.curIdx < s.queue.length - 1 ? s.curIdx + 1 : 0)
} else {
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}
} }
const onError = () => { const onError = () => {
if (!audio.src) return if (!audio.src) return
setTimeout(() => { setTimeout(() => { resumeContext(); audio.load(); audio.play().catch(() => {}) }, 1500)
resumeContext()
audio.load()
audio.play().catch(() => {})
}, 1500)
} }
audio.addEventListener('play', onPlay) audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause) audio.addEventListener('pause', onPause)
@@ -255,11 +262,10 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
} }
}, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd]) }, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd])
// Keep meta refs in sync for remote
useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta]) useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta])
useEffect(() => { coverSrcRef.current = coverSrc }, [coverSrc]) useEffect(() => { coverSrcRef.current = coverSrc }, [coverSrc])
// Remote: push state every 2s, poll commands every 500ms // Remote: push state every 2s, poll commands every 1.5s
useEffect(() => { useEffect(() => {
if (!roomId) return if (!roomId) return
@@ -331,7 +337,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
pushState() pushState()
pollCommands() pollCommands()
const ivState = setInterval(pushState, 2000) const ivState = setInterval(pushState, 2000)
const ivCmd = setInterval(pollCommands, 500) const ivCmd = setInterval(pollCommands, 1500)
return () => { clearInterval(ivState); clearInterval(ivCmd) } return () => { clearInterval(ivState); clearInterval(ivCmd) }
}, [roomId, audioRef, resumeContext]) }, [roomId, audioRef, resumeContext])
@@ -360,6 +366,13 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
const audio = audioRef.current const audio = audioRef.current
if (!audio) return if (!audio) return
if (audio.readyState === 0) {
if (loadingKeyRef.current < 0) {
const s = usePartyStore.getState()
if (s.curIdx >= 0 && s.queue.length > 0) s.setCurIdx(s.curIdx)
}
return
}
if (audio.paused) audio.play().catch(() => {}) if (audio.paused) audio.play().catch(() => {})
else audio.pause() else audio.pause()
}, [audioRef]) }, [audioRef])
@@ -368,331 +381,314 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) } const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) }
const nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) } const nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
const onDragStart = useCallback((idx: number, el: HTMLElement) => { const onTouchStart = (e: React.TouchEvent) => {
dragSrcIdx.current = idx touchStartX.current = e.touches[0].clientX
el.classList.add('dragging') }
}, []) const onTouchEnd = (e: React.TouchEvent) => {
const onDragEnd = useCallback((el: HTMLElement) => { if (touchStartX.current === null) return
el.classList.remove('dragging') const delta = e.changedTouches[0].clientX - touchStartX.current
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over')) touchStartX.current = null
}, []) if (delta > 60) prevTrack()
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => { else if (delta < -60) nextTrack()
e.preventDefault() }
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
reorderQueue(dragSrcIdx.current, tgtIdx)
dragSrcIdx.current = null
}, [reorderQueue])
const track = queue[Math.max(0, curIdx)] ?? null const track = queue[Math.max(0, curIdx)] ?? null
const trackTitle = track?.title ?? '' const trackTitle = track?.title ?? ''
const favorited = isFavorite(trackTitle) const favorited = isFavorite(trackTitle)
const progress = duration > 0 ? (currentTime / duration) * 100 : 0 const progress = duration > 0 ? (currentTime / duration) * 100 : 0
const repeatLabel = repeatMode === 'none' ? 'Повтор выкл' : repeatMode === 'all' ? 'Повтор всего' : 'Повтор трека'
return ( return (
<> <>
{/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */} {/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */}
<audio ref={audioRef} preload="auto" crossOrigin="anonymous" style={{ display: 'none' }} /> <audio ref={audioRef} preload="auto" crossOrigin="anonymous" style={{ display: 'none' }} />
{(queue.length > 0 && track) && <div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}> {(queue.length > 0 && track) && (
{/* Slide-up panels */} <div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}>
{panel && ( {/* Slide-up panels */}
<div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col"> {panel && (
{/* Versions panel */} <div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col">
{panel === 'versions' && results.length > 1 && ( {panel === 'versions' && results.length > 1 && (
<div className="overflow-y-auto"> <VersionsPanel
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between"> results={results}
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span> activeResultIdx={activeResultIdx}
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text"></button>
</div>
{results.map((r, i) => {
const saved = isSaved(trackTitle, r)
return (
<div
key={i}
onClick={() => playResult(results, i)}
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
>
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
{r.img && !r.img.includes('no-cover') && (
<img src={proxyImgUrl(r.img)} alt="" className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">{r.title}</div>
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
</div>
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
<button
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
title={saved ? 'Забыть версию' : 'Запомнить'}
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
{/* Queue panel */}
{panel === 'queue' && (
<div className="overflow-y-auto flex flex-col">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Очередь · {queue.length}</span>
<button
onClick={() => generateMix()}
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
></button>
</div>
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text"></button>
</div>
<div className="overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img src={item.img} alt="" className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color.bg, color: item.color.text }}>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Bottom bar */}
<div className="bg-surface border-t border-white/[0.07] px-4 py-2.5">
{/* Progress bar */}
<div className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group" onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const audio = audioRef.current
if (audio && duration) audio.currentTime = pct * duration
}}>
<div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
</div>
<div className="max-w-app mx-auto flex items-center gap-3">
{/* Cover */}
<div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
{coverSrc ? (
<img src={coverSrc} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-surface2" />
)}
<canvas ref={canvasRef} className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
{searchStatus === 'searching' ? (
<div className="flex items-center gap-1.5 text-[12px] text-muted">
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" />
<span>Ищем...</span>
</div>
) : (
<>
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">{track.title}</div>
<div className="flex items-center gap-1.5 mt-0.5">
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>{track.owner}</span>
</div>
</>
)}
</div>
{/* Time */}
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums">
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
<button onClick={prevTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
</button>
<button onClick={togglePlay} className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80">
{isPlaying ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
)}
</button>
<button onClick={nextTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
</button>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
{/* Reload */}
<button onClick={reloadTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
</button>
{/* Versions */}
{results.length > 1 && (
<button
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
title="Версии трека"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
{/* Add to playlist */}
<div className="relative">
<button
ref={playlistBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
title="Добавить в плейлист"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
{playlistOpen && (
<AddToPlaylist
trackTitle={trackTitle} trackTitle={trackTitle}
onClose={() => setPlaylistOpen(false)} onPlay={playResult}
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>} isSaved={isSaved}
saveVersion={saveVersion}
removeVersion={removeVersion}
onClose={() => setPanel(null)}
/>
)}
{panel === 'queue' && (
<QueuePanel
queue={queue}
curIdx={curIdx}
onClose={() => setPanel(null)}
/> />
)} )}
</div> </div>
{/* Favorite */} )}
<button
onClick={() => toggleFavorite(trackTitle)} {/* Bottom bar */}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2" <div
style={{ color: favorited ? 'var(--accent)' : undefined }} className="bg-surface border-t border-white/[0.07] px-4 py-2.5"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Progress bar */}
<div
role="slider"
aria-label="Прогресс трека"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const audio = audioRef.current
if (audio && duration) audio.currentTime = pct * duration
}}
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? 'var(--accent)' : 'none'} stroke={favorited ? 'var(--accent)' : 'currentColor'} strokeWidth="2"> <div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> <div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
</svg> </div>
</button>
{/* Queue */} <div className="max-w-app mx-auto flex items-center gap-3">
<button {/* Cover */}
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')} <div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer" {coverSrc ? (
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }} <img src={coverSrc} alt="Обложка" className="w-full h-full object-cover" />
title="Очередь" ) : (
> <div className="w-full h-full bg-surface2" />
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> )}
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /> <canvas
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" /> ref={canvasRef}
</svg> aria-hidden="true"
</button> className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`}
{/* Remote / Share */} />
<div className="relative share-popover-root"> </div>
<button
ref={shareBtnRef} {/* Track info */}
onClick={async () => { <div className="flex-1 min-w-0">
let id = roomId {searchStatus === 'searching' ? (
if (!id) { <div className="flex items-center gap-1.5 text-[12px] text-muted">
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null) <div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" aria-hidden="true" />
if (res?.ok) { <span>Ищем...</span>
const data = await res.json()
id = data.id as string
setRoomId(id)
}
}
if (id) setShareOpen(v => !v)
}}
title="Пульт управления"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" />
</svg>
</button>
{shareOpen && roomId && (
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
<div className="flex gap-1.5">
<input
readOnly
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
/>
<button
onClick={() => {
if (typeof window !== 'undefined') {
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
setShareCopied(true)
setTimeout(() => setShareCopied(false), 2000)
}
}}
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
>
{shareCopied ? '✓' : 'Копировать'}
</button>
</div> </div>
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p> ) : (
<>
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">
{track.title}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>
{track.owner}
</span>
</div>
</>
)}
</div>
{/* Time */}
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums" aria-label={`${formatTime(currentTime)} из ${formatTime(duration)}`}>
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
</div>
{/* Playback controls */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={prevTrack}
aria-label="Предыдущий трек"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
</button>
<button
onClick={togglePlay}
aria-label={isPlaying ? 'Пауза' : 'Воспроизвести'}
className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80"
>
{isPlaying ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
)}
</button>
<button
onClick={nextTrack}
aria-label="Следующий трек"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
</button>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
{/* Reload */}
<button
onClick={reloadTrack}
aria-label="Перезагрузить трек"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
</button>
{/* Repeat */}
<button
onClick={() => setRepeatMode(repeatMode === 'none' ? 'all' : repeatMode === 'all' ? 'one' : 'none')}
aria-label={repeatLabel}
title={repeatLabel}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hidden sm:flex relative"
style={{
color: repeatMode !== 'none' ? 'var(--accent)' : undefined,
background: repeatMode !== 'none' ? 'rgba(var(--accent-rgb),0.08)' : undefined,
}}
>
{repeatMode === 'one' ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
<text x="10" y="14" fontSize="6" fill="currentColor" stroke="none" fontWeight="bold">1</text>
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
style={{ opacity: repeatMode === 'none' ? 0.4 : 1 }}>
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
)}
</button>
{/* Versions */}
{results.length > 1 && (
<button
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
aria-label="Версии трека"
aria-expanded={panel === 'versions'}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
{/* Add to playlist */}
<div className="relative">
<button
ref={playlistBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
aria-label="Добавить в плейлист"
aria-expanded={playlistOpen}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
{playlistOpen && (
<AddToPlaylist
trackTitle={trackTitle}
onClose={() => setPlaylistOpen(false)}
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
/>
)}
</div> </div>
)}
{/* Favorite */}
<button
onClick={() => toggleFavorite(trackTitle)}
aria-label={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
style={{ color: favorited ? 'var(--accent)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? 'var(--accent)' : 'none'} stroke={favorited ? 'var(--accent)' : 'currentColor'} strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
{/* Queue */}
<button
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
aria-label="Очередь"
aria-expanded={panel === 'queue'}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
{/* Remote / Share */}
<div className="relative share-popover-root">
<button
ref={shareBtnRef}
onClick={async () => {
let id = roomId
if (!id) {
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null)
if (res?.ok) {
const data = await res.json()
id = data.id as string
setRoomId(id)
}
}
if (id) setShareOpen(v => !v)
}}
aria-label="Пульт управления"
aria-expanded={shareOpen}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" />
</svg>
</button>
{shareOpen && roomId && (
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
<div className="flex gap-1.5">
<input
readOnly
aria-label="Ссылка на пульт"
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
/>
<button
onClick={() => {
if (typeof window !== 'undefined') {
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
setShareCopied(true)
setTimeout(() => setShareCopied(false), 2000)
}
}}
aria-label="Копировать ссылку на пульт"
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
>
{shareCopied ? '✓' : 'Копировать'}
</button>
</div>
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> )}
</div>}
</> </>
) )
} }

View File

@@ -1,37 +1,101 @@
'use client' 'use client'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore' import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore' import { useVersionStore } from '@/store/versionStore'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { useOverlayStore } from '@/store/overlayStore'
import { audioState } from '@/lib/audioState'
import BottomPlayer from '@/components/BottomPlayer' import BottomPlayer from '@/components/BottomPlayer'
import type { SearchResult } from '@/types' import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
export default function GlobalPlayer() { export default function GlobalPlayer() {
const { hydrate: hydrateFavorites } = useFavoritesStore() const { hydrate: hydrateFavorites } = useFavoritesStore()
const { hydrate: hydrateVersions } = useVersionStore() const { hydrate: hydrateVersions } = useVersionStore()
const { token } = useAuthStore() const { user } = useAuthStore()
const lastPushedRef = useRef('')
useEffect(() => { hydrateFavorites() }, [hydrateFavorites])
useEffect(() => { hydrateVersions() }, [user, hydrateVersions])
const pushOverlay = useCallback(async (result: SearchResult | null) => {
if (!user) return
const { enabled, design, style, accentColor, position, font, textColor, showCover, showEq, palette, customPalettes, margin, scale, opacity } = useOverlayStore.getState()
const cp = customPalettes[style] ?? {}
const isPlaying = audioState.isPlaying
const key = `${result?.title}|${isPlaying}|${enabled}|${design}|${style}|${accentColor}|${position}|${font}|${textColor}|${showCover}|${showEq}|${palette}|${JSON.stringify(cp)}|${margin}|${scale}|${opacity}`
if (key === lastPushedRef.current) return
lastPushedRef.current = key
try {
await fetch(`${API_URL}/api/overlay/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: result?.title ?? '',
artist: result?.artist ?? '',
cover: result?.img ?? '',
is_playing: isPlaying,
progress: audioState.currentTime,
duration: audioState.duration,
enabled,
design,
style,
accent_color: accentColor,
position,
font,
text_color: textColor,
show_cover: showCover,
show_eq: showEq,
palette,
custom_bg: cp.bg ?? '',
custom_text: cp.text ?? '',
custom_text2: cp.text2 ?? '',
custom_chroma: cp.chroma ?? '',
custom_title_bg: cp.titleBg ?? '',
custom_body_bg: cp.bodyBg ?? '',
margin,
scale,
opacity,
}),
})
} catch {}
}, [user])
// Initial push on mount (and whenever user changes)
useEffect(() => {
pushOverlay(usePartyStore.getState().currentResult ?? null)
}, [pushOverlay])
useEffect(() => { useEffect(() => {
hydrateFavorites() return usePartyStore.subscribe((state, prev) => {
}, [hydrateFavorites]) const result = state.currentResult ?? null
const prevResult = prev.currentResult ?? null
if (result?.title !== prevResult?.title || state.curIdx !== prev.curIdx) {
pushOverlay(result)
}
})
}, [pushOverlay])
// Re-hydrate versions whenever auth changes (null → token = fetch from API)
useEffect(() => { useEffect(() => {
hydrateVersions() return useOverlayStore.subscribe(() => {
}, [token, hydrateVersions]) pushOverlay(usePartyStore.getState().currentResult ?? null)
})
}, [pushOverlay])
const handleTrackEnd = useCallback((result: SearchResult) => { const handleTrackEnd = useCallback((result: SearchResult) => {
const { queue, curIdx, addToHistory } = usePartyStore.getState() const { queue, curIdx, addToHistory } = usePartyStore.getState()
const track = queue[curIdx] const track = queue[curIdx]
if (!track) return if (!track) return
addToHistory({ addToHistory({
title: result.title, title: result.title,
artist: result.artist, artist: result.artist,
img: result.img, img: result.img,
owner: track.owner, owner: track.owner,
color: track.color, color: track.color,
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }), playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
}) })
}, []) }, [])

View File

@@ -10,8 +10,10 @@ export default function Header() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown on outside click
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
@@ -22,8 +24,12 @@ export default function Header() {
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler)
}, [open]) }, [open])
// Close mobile menu on navigation
useEffect(() => { setMenuOpen(false) }, [pathname])
const handleLogout = () => { const handleLogout = () => {
setOpen(false) setOpen(false)
setMenuOpen(false)
clearAuth() clearAuth()
router.push('/') router.push('/')
} }
@@ -33,6 +39,8 @@ export default function Header() {
return ( return (
<Link <Link
href={href} href={href}
aria-label={label}
aria-current={active ? 'page' : undefined}
className="flex items-center gap-1.5 text-[12px] font-display font-semibold px-3 py-1.5 rounded-xl border transition-all duration-150 hidden sm:flex" className="flex items-center gap-1.5 text-[12px] font-display font-semibold px-3 py-1.5 rounded-xl border transition-all duration-150 hidden sm:flex"
style={active style={active
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.3)', color: 'var(--accent)' } ? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.3)', color: 'var(--accent)' }
@@ -45,118 +53,213 @@ export default function Header() {
) )
} }
return ( const mobileNavLink = (href: string, label: string, icon: React.ReactNode) => {
<div className="flex items-center gap-2.5 mb-5 pb-5 border-b border-white/[0.07]"> const active = pathname === href
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline"> return (
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0"> <Link
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"> href={href}
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> onClick={() => setMenuOpen(false)}
<circle cx="6" cy="18" r="3" fill="#0a0a0f" /> aria-current={active ? 'page' : undefined}
<circle cx="18" cy="16" r="3" fill="#0a0a0f" /> className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
</svg> style={active
</div> ? { color: 'var(--accent)', background: 'rgba(var(--accent-rgb),0.06)' }
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1> : { color: 'var(--color-muted)' }
}
>
{icon}
{label}
</Link> </Link>
)
}
<div className="flex items-center gap-2 shrink-0"> const searchIcon = (
{navLink('/search', 'Поиск', <svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"> <circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" /> <path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg>
</svg> )
)} const communityIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
const playlistsIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
const settingsIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
{navLink('/community', 'Сообщество', return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"> <div className="relative mb-5">
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" /> <div className="flex items-center gap-2.5 pb-5 border-b border-white/[0.07]">
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> <Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline" aria-label="Party Mix — главная">
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> <div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0" aria-hidden="true">
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
</svg> <path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
)} <circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
</Link>
{user ? ( <div className="flex items-center gap-2 shrink-0">
<> {navLink('/search', 'Поиск', searchIcon)}
{navLink('/playlists', 'Плейлисты', {navLink('/community', 'Сообщество', communityIcon)}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> {user ? (
<>
{navLink('/playlists', 'Плейлисты', playlistsIcon)}
{/* User badge with dropdown */}
<div className="relative hidden sm:block" ref={dropdownRef}>
<button
onClick={() => setOpen(v => !v)}
aria-label={`Меню пользователя ${user.username}`}
aria-expanded={open}
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
>
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0" aria-hidden="true">
<span className="text-[10px] font-display font-extrabold text-accent">
{user.username[0].toUpperCase()}
</span>
</div>
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none"
aria-hidden="true"
className="text-muted transition-transform duration-200 ml-0.5"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
>
<Link
href="/settings"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
>
{settingsIcon}
Настройки
</Link>
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
<button
onClick={handleLogout}
aria-label="Выйти из аккаунта"
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Выйти
</button>
</div>
)}
</div>
</>
) : (
<>
<Link
href="/login"
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5 hidden sm:block"
>
Войти
</Link>
<Link
href="/register"
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150 hidden sm:block"
>
Регистрация
</Link>
</>
)}
{/* Hamburger — mobile only */}
<button
onClick={() => setMenuOpen(v => !v)}
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
aria-expanded={menuOpen}
className="w-8 h-8 flex items-center justify-center rounded-[8px] sm:hidden text-muted hover:text-app-text hover:bg-surface2 transition-all cursor-pointer"
>
{menuOpen ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
)} )}
</button>
{/* User badge with dropdown */} </div>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpen(v => !v)}
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
>
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0">
<span className="text-[10px] font-display font-extrabold text-accent">
{user.username[0].toUpperCase()}
</span>
</div>
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none"
className="text-muted transition-transform duration-200 ml-0.5"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
>
{/* Settings link */}
<Link
href="/settings"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Настройки
</Link>
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
{/* Logout */}
<button
onClick={handleLogout}
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Выйти
</button>
</div>
)}
</div>
</>
) : (
<>
<Link
href="/login"
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5"
>
Войти
</Link>
<Link
href="/register"
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
>
Регистрация
</Link>
</>
)}
</div> </div>
{/* Mobile menu drawer */}
{menuOpen && (
<div
className="absolute left-0 right-0 top-full bg-surface border border-white/[0.09] rounded-[12px] z-40 overflow-hidden shadow-xl flex flex-col sm:hidden"
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
>
{mobileNavLink('/search', 'Поиск', searchIcon)}
{mobileNavLink('/community', 'Сообщество', communityIcon)}
{user && mobileNavLink('/playlists', 'Плейлисты', playlistsIcon)}
<div className="border-t border-white/[0.07] mx-2 my-1" />
{user ? (
<>
{mobileNavLink('/settings', 'Настройки', settingsIcon)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150 cursor-pointer w-full text-left"
style={{ color: '#ff6b6b' }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Выйти
</button>
</>
) : (
<>
<Link
href="/login"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
style={{ color: 'var(--color-muted)' }}
>
Войти
</Link>
<Link
href="/register"
onClick={() => setMenuOpen(false)}
className="mx-3 mb-2 flex items-center justify-center py-2.5 text-[14px] font-display font-semibold bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
>
Регистрация
</Link>
</>
)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,10 +1,32 @@
'use client' 'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
import { useAuthStore } from '@/store/authStore'
import { useToastStore } from '@/store/toastStore'
import { createPlaylist } from '@/lib/authApi'
import { proxyImgUrl } from '@/lib/api' import { proxyImgUrl } from '@/lib/api'
export default function HistoryTab() { export default function HistoryTab() {
const { history, clearHistory } = usePartyStore() const { history, clearHistory } = usePartyStore()
const { user } = useAuthStore()
const showToast = useToastStore((s) => s.show)
const [saving, setSaving] = useState(false)
const handleSavePlaylist = async () => {
if (!history.length) return
setSaving(true)
try {
const tracks = [...history].reverse().map((h) => h.title)
const name = `История ${new Date().toLocaleDateString('ru', { day: 'numeric', month: 'short' })}`
await createPlaylist(name, tracks)
showToast('Плейлист сохранён', 'success')
} catch {
showToast('Не удалось сохранить плейлист', 'error')
} finally {
setSaving(false)
}
}
return ( return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp"> <div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
@@ -13,12 +35,23 @@ export default function HistoryTab() {
Уже сыграло Уже сыграло
</span> </span>
{history.length > 0 && ( {history.length > 0 && (
<button <div className="flex items-center gap-2">
onClick={clearHistory} {user && (
className="text-[11px] px-2.5 py-0.5 border border-[rgba(255,100,100,0.2)] rounded-[7px] bg-transparent text-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 cursor-pointer font-sans" <button
> onClick={handleSavePlaylist}
Очистить disabled={saving}
</button> className="text-[11px] px-2.5 py-0.5 border border-accent/20 rounded-[7px] text-accent/60 hover:bg-accent/08 hover:text-accent hover:border-accent/35 transition-all duration-150 cursor-pointer font-sans disabled:opacity-40"
>
{saving ? 'Сохраняем...' : '↓ В плейлист'}
</button>
)}
<button
onClick={clearHistory}
className="text-[11px] px-2.5 py-0.5 border border-[rgba(255,100,100,0.2)] rounded-[7px] bg-transparent text-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 cursor-pointer font-sans"
>
Очистить
</button>
</div>
)} )}
</div> </div>

View File

@@ -0,0 +1,489 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { getPalette, buildCustomPalette, type OverlayPalette } from '@/lib/overlayPalettes'
import { useOverlayStore, type OverlayStyle } from '@/store/overlayStore'
import { usePartyStore } from '@/store/partyStore'
// Shared state type used by both the overlay page and the settings preview
export interface OverlayWidgetState {
title: string
artist: string
cover: string
is_playing: boolean
progress: number
duration: number
enabled: boolean
design: string
style: string
accent_color: string
position: string
font: string
text_color: string
show_cover: boolean
show_eq: boolean
palette: string
custom_bg: string
custom_text: string
custom_text2: string
custom_chroma: string
custom_title_bg: string
custom_body_bg: string
margin: number
scale: number
opacity: number
updated_at: number
}
// ── Helpers ───────────────────────────────────────────────────────────────────
export function useCover(src: string) {
const [err, setErr] = useState(false)
useEffect(() => setErr(false), [src])
return { src: err ? '' : src, onError: () => setErr(true) }
}
export function EqBars({ color, size = 14, playing = true }: { color: string; size?: number; playing?: boolean }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2.5, height: size, flexShrink: 0 }}>
{[65, 100, 50, 85, 40].map((h, i) => (
<div key={i} className="eq-bar" style={{ height: `${h}%`, width: 2.5, background: color, borderRadius: 2, animationDelay: `${i * 0.13}s`, animationPlayState: playing ? 'running' : 'paused' }} />
))}
</div>
)
}
function MusicIcon() {
return (
<svg width="55%" height="55%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
)
}
function ProgressBar({ progress, duration, color, style: barStyle }: { progress: number; duration: number; color: string; style?: React.CSSProperties }) {
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0
if (!duration) return null
return (
<div style={{ height: 2, background: 'rgba(255,255,255,0.1)', borderRadius: 1, overflow: 'hidden', ...barStyle }}>
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 1, transition: 'width 1s linear' }} />
</div>
)
}
// position: fixed → in the overlay page, fixed to viewport.
// In the preview box (which has transform:translateZ(0)), fixed children are contained inside.
// --ov-scale is set by the overlay page per the user's scale setting.
// transform-origin follows --ov-origin (set based on position).
export const OVERLAY_POS: React.CSSProperties = {
position: 'fixed',
bottom: 'var(--ov-b)' as string,
top: 'var(--ov-t)' as string,
left: 'var(--ov-l)' as string,
right: 'var(--ov-r)' as string,
transform: 'scale(var(--ov-scale,1))',
transformOrigin: 'var(--ov-origin, bottom left)',
}
// ── 1. Классика ────────────────────────────────────────────────────────────────
export function ClassicStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const cover = useCover(s.cover)
const bg = pal.bg ?? 'rgba(10,10,16,0.82)'
const border = pal.border ?? '1px solid rgba(255,255,255,0.09)'
const shadow = pal.shadow ?? '0 8px 32px rgba(0,0,0,0.55)'
const text = s.text_color || pal.text || '#fff'
const text2 = pal.text2 ?? 'rgba(255,255,255,0.45)'
const coverBg = pal.chroma ?? 'rgba(255,255,255,0.06)'
return (
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', gap: 0, padding: '10px 16px 10px 10px', borderRadius: 16, background: bg, backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', border, boxShadow: shadow, maxWidth: 340 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{s.show_cover !== false && (
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', background: coverBg, flexShrink: 0 }}>
{cover.src
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}><MusicIcon /></div>
}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
</div>
{s.show_eq !== false && <EqBars color="var(--accent)" playing={s.is_playing} />}
</div>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 8 }} />
</div>
)
}
// ── 2. Фрутигер Аеро ──────────────────────────────────────────────────────────
export function AeroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const cover = useCover(s.cover)
const bg = pal.bg ?? 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)'
const border = pal.border ?? '1.5px solid rgba(255,255,255,0.72)'
const shadow = pal.shadow ?? '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)'
const text = s.text_color || pal.text || '#003a6e'
const text2 = pal.text2 ?? 'rgba(0,60,130,0.60)'
return (
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', borderRadius: 50, background: bg, backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', border, boxShadow: shadow, maxWidth: 340, overflow: 'hidden', position: 'fixed' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '55%', background: 'linear-gradient(180deg,rgba(255,255,255,0.38) 0%,rgba(255,255,255,0) 100%)', borderRadius: '50px 50px 0 0', pointerEvents: 'none' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 20px 8px 8px' }}>
{s.show_cover !== false && (
<div style={{ width: 42, height: 42, borderRadius: '50%', overflow: 'hidden', background: 'rgba(255,255,255,0.25)', flexShrink: 0, border: '2px solid rgba(255,255,255,0.6)', boxShadow: '0 2px 8px rgba(0,80,180,0.2)' }}>
{cover.src
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(0,80,160,0.5)' }}><MusicIcon /></div>
}
</div>
)}
<div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.02em' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
</div>
{s.show_eq !== false && <EqBars color="var(--accent)" size={12} playing={s.is_playing} />}
</div>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ borderRadius: 0, margin: '0 0 0 0' }} />
</div>
)
}
// ── 3. Ретро ──────────────────────────────────────────────────────────────────
export function RetroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const [blink, setBlink] = useState(true)
useEffect(() => { const iv = setInterval(() => setBlink(b => !b), 800); return () => clearInterval(iv) }, [])
const now = new Date()
const ts = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`
const bg = pal.bg ?? 'rgba(12,5,0,0.94)'
const border = pal.border ?? '2px solid #c07030'
const shadow = pal.shadow ?? '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)'
const chromaA = pal.chroma ?? 'rgba(180,100,20,0.30)'
const text = s.text_color || pal.text || '#f8d090'
const text2 = pal.text2 ?? '#9a6020'
const blinkColor = pal.chroma?.startsWith('#') ? pal.chroma : 'var(--accent)'
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 0
return (
<div style={{ ...OVERLAY_POS, width: 310, fontFamily: s.font || 'monospace', background: bg, border, boxShadow: shadow, overflow: 'hidden' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px)', pointerEvents: 'none', zIndex: 2 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: chromaA, borderBottom: `1px solid ${blinkColor}44` }}>
<span style={{ fontSize: 10, color: blinkColor, letterSpacing: '0.1em' }}> NOW PLAYING</span>
<span style={{ fontSize: 10, color: blink ? blinkColor : 'transparent', letterSpacing: '0.05em' }}> REC</span>
</div>
<div style={{ padding: '10px 12px 8px', position: 'relative', zIndex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, letterSpacing: '0.05em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
{s.duration > 0 && (
<div style={{ marginTop: 8, height: 3, background: 'rgba(255,255,255,0.1)', borderRadius: 0 }}>
<div style={{ height: '100%', width: `${pct}%`, background: blinkColor, transition: 'width 1s linear' }} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
<span style={{ fontSize: 9, color: text2, fontFamily: 'monospace' }}>{ts}</span>
</div>
</div>
</div>
)
}
// ── 4. Неон ───────────────────────────────────────────────────────────────────
export function NeonStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const bg = pal.bg ?? 'rgba(0,0,10,0.9)'
const vars = pal.chroma && pal.chroma2
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
: {}
const text = s.text_color || 'var(--accent)'
return (
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 18px', background: bg, backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', border: '1px solid rgba(var(--accent-rgb),0.6)', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3),inset 0 0 20px rgba(var(--accent-rgb),0.04)', borderRadius: 8, maxWidth: 340 }}>
{[['0','0'],['0','auto'],['auto','0'],['auto','auto']].map(([t,b], i) => (
<div key={i} style={{ position: 'absolute', top: t === '0' ? -1 : 'auto', bottom: b === 'auto' ? 'auto' : -1, left: i < 2 ? -1 : 'auto', right: i < 2 ? 'auto' : -1, width: 8, height: 8, border: '2px solid var(--accent)', borderRadius: 1 }} />
))}
<div style={{ fontSize: 10, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.2em', marginBottom: 6, fontFamily: 'monospace' }}> STREAM</div>
<div style={{ fontSize: 16, fontWeight: 800, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', textShadow: '0 0 12px rgba(var(--accent-rgb),0.8)' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.5)', marginTop: 4, letterSpacing: '0.06em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.15)' }} />
{(!s.duration) && <div style={{ flex: 1, height: 1, background: 'linear-gradient(90deg,rgba(var(--accent-rgb),0.6),transparent)' }} />}
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
</div>
</div>
)
}
// ── 5. Минимализм ─────────────────────────────────────────────────────────────
export function CleanStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const text = s.text_color || pal.text || '#ffffff'
const text2 = pal.text2 ?? 'rgba(255,255,255,0.55)'
return (
<div style={{ ...OVERLAY_POS }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 4, height: 32, background: 'var(--accent)', borderRadius: 2, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 22, fontWeight: 800, color: text, lineHeight: 1.1, letterSpacing: '-0.03em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 400, textShadow: '0 2px 16px rgba(0,0,0,0.8)' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 13, color: text2, marginTop: 3, letterSpacing: '-0.01em', textShadow: '0 2px 8px rgba(0,0,0,0.8)' }}>{s.artist}</div>}
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 6, width: 200 }} />
</div>
</div>
</div>
)
}
// ── 6. Y2K ────────────────────────────────────────────────────────────────────
export function Y2kStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const titleBg = pal.titleBg ?? 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)'
const bodyBg = pal.bodyBg ?? 'linear-gradient(160deg,#dce4f0,#c0c8e0)'
const text = s.text_color || pal.text || '#000060'
const text2 = pal.text2 ?? '#404880'
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 42
return (
<div style={{ ...OVERLAY_POS, width: 300, fontFamily: s.font || 'Tahoma, Arial, sans-serif', boxShadow: '3px 3px 0 rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.9)', border: '2px solid #7080b0', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '3px 4px 3px 6px', background: titleBg, userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 9 }}>🎵</span>
<span style={{ fontSize: 11, color: '#fff', fontWeight: 700, letterSpacing: '0.01em' }}>Party Mix Player</span>
</div>
<div style={{ display: 'flex', gap: 2 }}>
{['_','□','×'].map(c => (
<div key={c} style={{ width: 16, height: 14, background: 'linear-gradient(180deg,#d0d8e8,#9aa8c0)', border: '1px solid #607090', borderRadius: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, color: '#000', fontWeight: 700, cursor: 'default' }}>{c}</div>
))}
</div>
</div>
<div style={{ background: bodyBg, padding: '10px 12px 12px' }}>
<div style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid #8090b0', borderRadius: 2, padding: '6px 10px', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 10, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
</div>
<div style={{ height: 14, background: 'rgba(255,255,255,0.4)', border: '1px solid #8090b0', borderRadius: 2, overflow: 'hidden', boxShadow: 'inset 1px 1px 2px rgba(0,0,0,0.15)' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'linear-gradient(180deg,var(--accent),rgba(var(--accent-rgb),0.65))', borderRadius: 1, transition: 'width 1s linear' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 3 }}>
<span style={{ fontSize: 9, color: text2 }}> {s.is_playing ? 'Playing' : 'Paused'}</span>
<span style={{ fontSize: 9, color: text2 }}>WMP 9.0</span>
</div>
</div>
</div>
)
}
// ── 7. Ло-фай ─────────────────────────────────────────────────────────────────
export function LofiStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const cover = useCover(s.cover)
const bg = pal.bg ?? 'rgba(42,26,14,0.92)'
const text = s.text_color || pal.text || '#fde8c0'
const text2 = pal.text2 ?? 'rgba(var(--accent-rgb),0.60)'
return (
<div style={{ ...OVERLAY_POS, display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px', borderRadius: 18, background: bg, backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', border: '1px solid rgba(var(--accent-rgb),0.2)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', transform: `rotate(-0.8deg) scale(var(--ov-scale,1))`, maxWidth: 320 }}>
{s.show_cover !== false && (
<div style={{ width: 52, height: 52, borderRadius: '50%', flexShrink: 0, background: 'radial-gradient(circle at center,#3a2010 0%,#1a0e06 40%,#2a1808 60%,#1a0e06 100%)', border: '2px solid rgba(var(--accent-rgb),0.3)', boxShadow: '0 2px 12px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-radial-gradient(circle at center,transparent 0%,transparent 14%,rgba(255,255,255,0.03) 14.5%,transparent 15%)', borderRadius: '50%' }} />
{cover.src
? <div style={{ width: 22, height: 22, borderRadius: '50%', overflow: 'hidden', zIndex: 1 }}><img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" /></div>
: <div style={{ width: 10, height: 10, borderRadius: '50%', background: 'rgba(var(--accent-rgb),0.5)', zIndex: 1 }} />
}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.55)', letterSpacing: '0.15em', marginBottom: 4, fontStyle: 'italic' }}>now playing</div>
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic', letterSpacing: '0.01em' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic' }}>{s.artist}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.2)' }} />
{!s.duration && <div style={{ flex: 1, height: 1, background: 'rgba(var(--accent-rgb),0.25)' }} />}
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
</div>
</div>
</div>
)
}
// ── 8. Гламур ─────────────────────────────────────────────────────────────────
export function GlamStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const bg = pal.bg ?? 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))'
const chroma = pal.chroma ?? '212,175,55'
const text = s.text_color || pal.text || `rgb(${chroma})`
const text2 = pal.text2 ?? `rgba(${chroma},0.65)`
const metalG = `linear-gradient(90deg,transparent,rgba(${chroma},0.8) 30%,rgb(${chroma}) 50%,rgba(${chroma},0.8) 70%,transparent)`
return (
<div style={{ ...OVERLAY_POS, padding: '16px 20px', background: bg, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', border: `1px solid rgba(${chroma},0.45)`, boxShadow: `0 8px 40px rgba(0,0,0,0.7),0 0 0 1px rgba(${chroma},0.2),inset 0 1px 0 rgba(${chroma},0.15)`, borderRadius: 14, maxWidth: 340, overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: metalG }} />
<div style={{ position: 'absolute', top: 6, left: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}></div>
<div style={{ position: 'absolute', top: 6, right: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}></div>
<div style={{ textAlign: 'center', fontSize: 8, color: `rgba(${chroma},0.5)`, letterSpacing: '0.3em', marginBottom: 8, textTransform: 'uppercase' }}>Now Playing</div>
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginBottom: 10 }} />
<div style={{ fontSize: 16, fontWeight: 700, color: text, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '0.02em', textShadow: `0 0 20px rgba(${chroma},0.4)` }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, textAlign: 'center', marginTop: 5, letterSpacing: '0.08em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginTop: 10 }} />
{s.show_eq !== false && <div style={{ textAlign: 'center', marginTop: 6 }}><EqBars color={`rgb(${chroma})`} size={10} playing={s.is_playing} /></div>}
<ProgressBar progress={s.progress} duration={s.duration} color={`rgb(${chroma})`} style={{ marginTop: 8, background: `rgba(${chroma},0.15)` }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: metalG }} />
</div>
)
}
// ── 9. Матрица ────────────────────────────────────────────────────────────────
export function MatrixStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const [cursor, setCursor] = useState(true)
const [dots, setDots] = useState(0)
useEffect(() => {
const iv = setInterval(() => { setCursor(c => !c); setDots(d => (d + 1) % 4) }, 530)
return () => clearInterval(iv)
}, [])
const bg = pal.bg ?? 'rgba(0,8,0,0.93)'
const border = pal.border ?? '1px solid rgba(0,255,50,0.35)'
const vars = pal.chroma && pal.chroma2
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
: {}
const text = s.text_color || pal.chroma || '#00ff32'
return (
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 16px', background: bg, backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', border, boxShadow: '0 0 24px rgba(var(--accent-rgb),0.15),inset 0 0 12px rgba(var(--accent-rgb),0.03)', borderRadius: 4, maxWidth: 360, fontFamily: s.font || '"Courier New",Courier,monospace' }}>
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.08em', marginBottom: 6 }}>PARTY_MIX@STREAM:~$ play --now</div>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{ fontSize: 12, color: 'rgba(var(--accent-rgb),0.6)', marginRight: 6 }}>&gt;</span>
<span style={{ fontSize: 15, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 280, textShadow: '0 0 8px rgba(var(--accent-rgb),0.6)' }}>{s.title || 'NULL'}</span>
<span style={{ color: text, opacity: cursor ? 1 : 0, marginLeft: 2, textShadow: '0 0 6px rgba(var(--accent-rgb),0.8)' }}></span>
</div>
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.55)', marginTop: 3, letterSpacing: '0.05em' }}>{`:: artist="${s.artist}"`}</div>}
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.4)', marginTop: 6, letterSpacing: '0.05em' }}>{`[LOADING${'.'.repeat(dots)}]`}</div>
</div>
)
}
// ── Style map ─────────────────────────────────────────────────────────────────
export const OVERLAY_STYLE_MAP: Record<string, (s: OverlayWidgetState, pal: OverlayPalette) => React.ReactNode> = {
classic: (s, pal) => <ClassicStyle s={s} pal={pal} />,
aero: (s, pal) => <AeroStyle s={s} pal={pal} />,
retro: (s, pal) => <RetroStyle s={s} pal={pal} />,
neon: (s, pal) => <NeonStyle s={s} pal={pal} />,
clean: (s, pal) => <CleanStyle s={s} pal={pal} />,
y2k: (s, pal) => <Y2kStyle s={s} pal={pal} />,
lofi: (s, pal) => <LofiStyle s={s} pal={pal} />,
glam: (s, pal) => <GlamStyle s={s} pal={pal} />,
matrix: (s, pal) => <MatrixStyle s={s} pal={pal} />,
}
// ── Settings preview ──────────────────────────────────────────────────────────
export function OverlayPreview() {
const {
style: overlayStyle,
palette: overlayPalette,
customPalettes,
accentColor,
textColor,
showCover,
showEq,
font,
} = useOverlayStore()
const currentResult = usePartyStore(s => s.currentResult)
const cp = customPalettes[overlayStyle] ?? {}
const pal = overlayPalette === 'custom'
? buildCustomPalette(overlayStyle as OverlayStyle, {
bg: cp.bg ?? '',
text: cp.text ?? '',
text2: cp.text2 ?? '',
chroma: cp.chroma ?? '',
titleBg: cp.titleBg ?? '',
bodyBg: cp.bodyBg ?? '',
})
: getPalette(overlayStyle as OverlayStyle, overlayPalette)
const hex = /^#[0-9a-fA-F]{6}$/.test(accentColor) ? accentColor : '#de9cfe'
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
// Use real track if playing, otherwise mock
const mockState: OverlayWidgetState = {
title: currentResult?.title ?? 'Название трека',
artist: currentResult?.artist ?? 'Артист',
cover: currentResult?.img ?? '',
is_playing: true,
progress: 0,
duration: 0,
enabled: true,
design: 'minimal',
style: overlayStyle,
accent_color: hex,
position: 'bl',
font,
text_color: textColor,
show_cover: showCover,
show_eq: showEq,
palette: overlayPalette,
custom_bg: cp.bg ?? '',
custom_text: cp.text ?? '',
custom_text2: cp.text2 ?? '',
custom_chroma: cp.chroma ?? '',
custom_title_bg: cp.titleBg ?? '',
custom_body_bg: cp.bodyBg ?? '',
margin: 24,
scale: 1,
opacity: 1,
updated_at: 0,
}
const render = OVERLAY_STYLE_MAP[overlayStyle] ?? OVERLAY_STYLE_MAP.classic
// Re-key on style change to replay enter animation
const animKey = overlayStyle + overlayPalette
return (
<div
style={{
position: 'relative',
height: 160,
borderRadius: 12,
overflow: 'hidden',
// transform creates a new containing block for position:fixed children
transform: 'translateZ(0)',
'--accent': hex,
'--accent-rgb': `${r},${g},${b}`,
'--ov-b': '20px',
'--ov-t': 'auto',
'--ov-l': '20px',
'--ov-r': 'auto',
'--ov-scale': '1',
'--ov-origin': 'bottom left',
} as React.CSSProperties}
>
{/* "stream" background */}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(8,8,12,0.97)' }} />
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(255,255,255,0.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.022) 1px,transparent 1px)', backgroundSize: '22px 22px' }} />
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at 15% 80%,rgba(${r},${g},${b},0.10) 0%,transparent 55%)` }} />
<div key={animKey}>{render(mockState, pal)}</div>
</div>
)
}
// ── Local progress hook (for overlay page) ────────────────────────────────────
export function useLocalProgress(serverProgress: number, serverDuration: number, isPlaying: boolean, updatedAt: number) {
const [progress, setProgress] = useState(serverProgress)
const startRef = useRef({ t: Date.now(), p: serverProgress })
useEffect(() => {
startRef.current = { t: Date.now(), p: serverProgress }
setProgress(serverProgress)
}, [serverProgress, updatedAt])
useEffect(() => {
if (!isPlaying || !serverDuration) return
const iv = setInterval(() => {
const elapsed = (Date.now() - startRef.current.t) / 1000
setProgress(Math.min(serverDuration, startRef.current.p + elapsed))
}, 500)
return () => clearInterval(iv)
}, [isPlaying, serverDuration])
return progress
}

View File

@@ -8,7 +8,7 @@ import type { Playlist } from '@/types'
export default function AddPersonForm() { export default function AddPersonForm() {
const { addPerson } = usePartyStore() const { addPerson } = usePartyStore()
const { token } = useAuthStore() const { user } = useAuthStore()
const [name, setName] = useState('') const [name, setName] = useState('')
const [tracks, setTracks] = useState('') const [tracks, setTracks] = useState('')
@@ -18,17 +18,17 @@ export default function AddPersonForm() {
const [loadingPlaylist, setLoadingPlaylist] = useState(false) const [loadingPlaylist, setLoadingPlaylist] = useState(false)
useEffect(() => { useEffect(() => {
if (!token) return if (!user) return
getPlaylists(token) getPlaylists()
.then(setPlaylists) .then(setPlaylists)
.catch(() => {}) .catch(() => {})
}, [token]) }, [user])
const handleSelectPlaylist = async (id: string) => { const handleSelectPlaylist = async (id: string) => {
if (!token || !id) return if (!user || !id) return
setLoadingPlaylist(true) setLoadingPlaylist(true)
try { try {
const pl = await getPlaylist(token, id) const pl = await getPlaylist(id)
if (pl.tracks?.length) { if (pl.tracks?.length) {
setTracks(pl.tracks.map((t) => t.title).join('\n')) setTracks(pl.tracks.map((t) => t.title).join('\n'))
} }
@@ -69,7 +69,7 @@ export default function AddPersonForm() {
onKeyDown={(e) => e.key === 'Enter' && handleAdd()} onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/> />
{token && playlists.length > 0 && ( {user && playlists.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<select <select
onChange={(e) => handleSelectPlaylist(e.target.value)} onChange={(e) => handleSelectPlaylist(e.target.value)}

View File

@@ -1,11 +1,23 @@
'use client' 'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
import PersonCard from './PersonCard' import PersonCard from './PersonCard'
import AddPersonForm from './AddPersonForm' import AddPersonForm from './AddPersonForm'
export default function PartyTab() { export default function PartyTab() {
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore() const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
const [hint, setHint] = useState(false)
const handleGenerate = () => {
if (!people.length) {
setHint(true)
setTimeout(() => setHint(false), 2500)
return
}
setHint(false)
generateMix()
}
return ( return (
<div className="animate-fadeUp"> <div className="animate-fadeUp">
@@ -31,14 +43,8 @@ export default function PartyTab() {
)} )}
<button <button
onClick={() => { onClick={handleGenerate}
if (!people.length) { className="w-full py-3.5 mb-1.5 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
alert('Добавьте участников!')
return
}
generateMix()
}}
className="w-full py-3.5 mb-4 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
> >
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8" /> <polyline points="16 3 21 3 21 8" />
@@ -49,7 +55,17 @@ export default function PartyTab() {
Перемешать и включить Перемешать и включить
</button> </button>
<div className="flex flex-col gap-2.5 mb-4"> <div className={`overflow-hidden transition-all duration-300 ${hint ? 'max-h-10 mb-2.5' : 'max-h-0 mb-0'}`}>
<div className="flex items-center gap-2 text-[12px] text-[#ffb86b] bg-[rgba(255,184,107,0.08)] border border-[rgba(255,184,107,0.18)] px-3 py-1.5 rounded-[9px]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="shrink-0">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M12 8v4M12 16h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Сначала добавьте участников ниже
</div>
</div>
<div className="flex flex-col gap-2.5 mb-4 mt-2.5">
{people.map((person, i) => ( {people.map((person, i) => (
<PersonCard key={`${person.name}-${i}`} person={person} index={i} /> <PersonCard key={`${person.name}-${i}`} person={person} index={i} />
))} ))}
@@ -59,3 +75,4 @@ export default function PartyTab() {
</div> </div>
) )
} }

View File

@@ -0,0 +1,116 @@
'use client'
import { useRef, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import type { QueueItem } from '@/types'
interface Props {
queue: QueueItem[]
curIdx: number
onClose: () => void
}
export default function QueuePanel({ queue, curIdx, onClose }: Props) {
const { setCurIdx, generateMix, reorderQueue } = usePartyStore()
const dragSrc = useRef<number | null>(null)
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
dragSrc.current = idx
el.classList.add('dragging')
}, [])
const onDragEnd = useCallback((el: HTMLElement) => {
el.classList.remove('dragging')
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
}, [])
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
e.preventDefault()
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrc.current === null || dragSrc.current === tgtIdx) return
reorderQueue(dragSrc.current, tgtIdx)
dragSrc.current = null
}, [reorderQueue])
return (
<div className="overflow-y-auto flex flex-col">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">
Очередь · {queue.length}
</span>
<button
onClick={() => generateMix()}
aria-label="Пересортировать очередь"
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
></button>
</div>
<button
onClick={onClose}
aria-label="Закрыть очередь"
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
></button>
</div>
<div className="overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div
className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0" aria-label="Играет">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img
src={item.img}
alt=""
className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{item.title}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium"
style={{ background: item.color.bg, color: item.color.text }}
>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import { proxyImgUrl } from '@/lib/api'
import type { SearchResult } from '@/types'
interface Props {
results: SearchResult[]
activeResultIdx: number
trackTitle: string
onPlay: (results: SearchResult[], i: number) => void
isSaved: (title: string, r: SearchResult) => boolean
saveVersion: (title: string, r: SearchResult) => void
removeVersion: (title: string) => void
onClose: () => void
}
export default function VersionsPanel({
results, activeResultIdx, trackTitle, onPlay, isSaved, saveVersion, removeVersion, onClose,
}: Props) {
return (
<div className="overflow-y-auto">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
<button
onClick={onClose}
aria-label="Закрыть версии"
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
></button>
</div>
{results.map((r, i) => {
const saved = isSaved(trackTitle, r)
return (
<div
key={i}
onClick={() => onPlay(results, i)}
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
>
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
{r.img && !r.img.includes('no-cover') && (
<img
src={proxyImgUrl(r.img)}
alt=""
className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">
{r.title}
</div>
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
</div>
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
<button
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
aria-label={saved ? 'Забыть версию' : 'Запомнить версию'}
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
style={{
borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)',
background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent',
}}
>
<svg
width="10" height="10" viewBox="0 0 24 24"
fill={saved ? 'var(--accent)' : 'none'}
stroke={saved ? 'var(--accent)' : '#555'}
strokeWidth="2"
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onPlay(results, i) }}
aria-label={i === activeResultIdx ? 'Воспроизводится' : 'Воспроизвести эту версию'}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}>
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
)
})}
</div>
)
}

View File

@@ -1,10 +1,28 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore' import { usePartyStore } from '@/store/partyStore'
import { fetchTopCharts } from '@/lib/api' import { fetchTopCharts } from '@/lib/api'
import type { SearchResult } from '@/types' import type { SearchResult } from '@/types'
const SEARCH_HISTORY_KEY = 'pm_search_history'
const MAX_HISTORY = 8
function getSearchHistory(): string[] {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
}
function addToSearchHistory(q: string) {
const prev = getSearchHistory().filter((s) => s !== q)
const next = [q, ...prev].slice(0, MAX_HISTORY)
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
}
function clearSearchHistory() {
try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {}
}
function TopChartsCard({ tracks }: { tracks: string[] }) { function TopChartsCard({ tracks }: { tracks: string[] }) {
const { loadPlaylist } = usePartyStore() const { loadPlaylist } = usePartyStore()
const [launched, setLaunched] = useState(false) const [launched, setLaunched] = useState(false)
@@ -48,24 +66,34 @@ function TopChartsCard({ tracks }: { tracks: string[] }) {
export default function SoloTab() { export default function SoloTab() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [topTracks, setTopTracks] = useState<string[] | null>(null) const [topTracks, setTopTracks] = useState<string[] | null>(null)
const [searchHistory, setSearchHistory] = useState<string[]>([])
const { loadPlaylist } = usePartyStore() const { loadPlaylist } = usePartyStore()
useEffect(() => { useEffect(() => {
setSearchHistory(getSearchHistory())
fetchTopCharts().then((results: SearchResult[]) => { fetchTopCharts().then((results: SearchResult[]) => {
if (!results.length) { setTopTracks([]); return } if (!results.length) { setTopTracks([]); return }
setTopTracks( setTopTracks(results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title)))
results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title))
)
}) })
}, []) }, [])
const handleSearch = (e: React.FormEvent) => { const doSearch = useCallback((q: string) => {
e.preventDefault() if (!q.trim()) return
const q = search.trim() addToSearchHistory(q.trim())
if (!q) return setSearchHistory(getSearchHistory())
loadPlaylist([q]) loadPlaylist([q.trim()])
setSearch('') setSearch('')
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
}, [loadPlaylist])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
doSearch(search)
}
const handleClearHistory = () => {
clearSearchHistory()
setSearchHistory([])
} }
return ( return (
@@ -100,6 +128,31 @@ export default function SoloTab() {
</button> </button>
</form> </form>
{searchHistory.length > 0 && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-muted font-display uppercase tracking-[1px]">Недавние</span>
<button
onClick={handleClearHistory}
className="text-[10px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{searchHistory.map((q) => (
<button
key={q}
onClick={() => doSearch(q)}
className="text-[11px] px-2.5 py-1 rounded-[7px] bg-surface2 text-muted hover:text-app-text hover:bg-white/[0.07] transition-all cursor-pointer truncate max-w-[200px]"
>
{q}
</button>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,38 @@
'use client'
import { useToastStore } from '@/store/toastStore'
export default function Toaster() {
const { toasts, dismiss } = useToastStore()
if (!toasts.length) return null
return (
<div className="fixed bottom-[84px] left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 z-[9999] pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
onClick={() => dismiss(t.id)}
className="pointer-events-auto flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-[13px] font-sans shadow-xl cursor-pointer animate-fadeUp"
style={{
background: t.type === 'error' ? 'rgba(255,80,80,0.12)' : t.type === 'success' ? 'rgba(80,200,120,0.12)' : 'rgba(255,255,255,0.08)',
border: `1px solid ${t.type === 'error' ? 'rgba(255,80,80,0.25)' : t.type === 'success' ? 'rgba(80,200,120,0.25)' : 'rgba(255,255,255,0.1)'}`,
color: t.type === 'error' ? '#ff6b6b' : t.type === 'success' ? '#5cc87a' : '#ccc',
backdropFilter: 'blur(12px)',
}}
>
{t.type === 'error' && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
{t.type === 'success' && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{t.message}
</div>
))}
</div>
)
}

View File

@@ -31,6 +31,15 @@ export async function fetchYandexPlaylist(yandexUrl: string): Promise<{ name: st
return data return data
} }
export async function fetchSpotifyPlaylist(spotifyUrl: string): Promise<{ name: string; tracks: string[] }> {
const res = await fetch(`${API_URL}/api/proxy/spotify-playlist?url=${encodeURIComponent(spotifyUrl)}`, {
signal: AbortSignal.timeout(20_000),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Ошибка загрузки Spotify плейлиста')
return data
}
export async function searchTracks(query: string): Promise<SearchResult[]> { export async function searchTracks(query: string): Promise<SearchResult[]> {
try { try {
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, { const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {

View File

@@ -1,4 +1,6 @@
export const audioState = { export const audioState = {
analyser: null as AnalyserNode | null, analyser: null as AnalyserNode | null,
isPlaying: false, isPlaying: false,
currentTime: 0,
duration: 0,
} }

View File

@@ -4,9 +4,22 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, { const res = await fetch(`${API_URL}${path}`, {
credentials: 'include',
...options, ...options,
headers: { 'Content-Type': 'application/json', ...options.headers }, headers: { 'Content-Type': 'application/json', ...options.headers },
}) })
if (res.status === 401) {
if (typeof window !== 'undefined') {
const pub = ['/login', '/register', '/']
const isPublic = pub.includes(window.location.pathname) || window.location.pathname.startsWith('/remote/')
if (!isPublic) {
window.location.href = '/login'
}
}
throw new Error('Unauthorized')
}
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`) throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
@@ -15,10 +28,6 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return res.json() return res.json()
} }
function bearer(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` }
}
export async function register(username: string, email: string, password: string): Promise<User> { export async function register(username: string, email: string, password: string): Promise<User> {
return request<User>('/api/auth/register', { return request<User>('/api/auth/register', {
method: 'POST', method: 'POST',
@@ -26,31 +35,35 @@ export async function register(username: string, email: string, password: string
}) })
} }
export async function login(email: string, password: string): Promise<{ token: string; user: User }> { export async function login(email: string, password: string): Promise<User> {
return request<{ token: string; user: User }>('/api/auth/login', { const res = await request<{ user: User }>('/api/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
return res.user
} }
export async function fetchMe(token: string): Promise<User> { export async function logout(): Promise<void> {
return request<User>('/api/auth/me', { headers: bearer(token) }) await request<void>('/api/auth/logout', { method: 'POST' }).catch(() => {})
} }
export async function getPlaylists(token: string): Promise<Playlist[]> { export async function fetchMe(): Promise<User> {
return request<Playlist[]>('/api/playlists', { headers: bearer(token) }) return request<User>('/api/auth/me')
}
export async function getPlaylists(): Promise<Playlist[]> {
return request<Playlist[]>('/api/playlists')
} }
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> { export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
return request<PublicPlaylist[]>('/api/playlists/public') return request<PublicPlaylist[]>('/api/playlists/public')
} }
export async function getPlaylist(token: string, id: string): Promise<Playlist> { export async function getPlaylist(id: string): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, { headers: bearer(token) }) return request<Playlist>(`/api/playlists/${id}`)
} }
export async function createPlaylist( export async function createPlaylist(
token: string,
name: string, name: string,
tracks: string[], tracks: string[],
isPublic = false, isPublic = false,
@@ -58,13 +71,11 @@ export async function createPlaylist(
): Promise<Playlist> { ): Promise<Playlist> {
return request<Playlist>('/api/playlists', { return request<Playlist>('/api/playlists', {
method: 'POST', method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }), body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
}) })
} }
export async function updatePlaylist( export async function updatePlaylist(
token: string,
id: string, id: string,
name: string, name: string,
tracks: string[], tracks: string[],
@@ -73,22 +84,17 @@ export async function updatePlaylist(
): Promise<Playlist> { ): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, { return request<Playlist>(`/api/playlists/${id}`, {
method: 'PUT', method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }), body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
}) })
} }
export async function addTrackToPlaylist(token: string, playlistId: string, title: string): Promise<void> { export async function addTrackToPlaylist(playlistId: string, title: string): Promise<void> {
return request<void>(`/api/playlists/${playlistId}/tracks`, { return request<void>(`/api/playlists/${playlistId}/tracks`, {
method: 'POST', method: 'POST',
headers: bearer(token),
body: JSON.stringify({ title }), body: JSON.stringify({ title }),
}) })
} }
export async function deletePlaylist(token: string, id: string): Promise<void> { export async function deletePlaylist(id: string): Promise<void> {
return request<void>(`/api/playlists/${id}`, { return request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
method: 'DELETE',
headers: bearer(token),
})
} }

View File

@@ -0,0 +1,368 @@
import type { OverlayStyle } from '@/store/overlayStore'
export interface OverlayPalette {
id: string
name: string
swatches: string[] // preview dots (2-3 hex/rgba)
bg?: string // panel background (color or gradient)
border?: string // full CSS border declaration
shadow?: string // box-shadow
text?: string // primary text color
text2?: string // secondary text color
chroma?: string // characteristic color: retro border hex, neon/matrix phosphor hex, glam metal rgb-string
chroma2?: string // for RGB string "r,g,b" when chroma is hex (used for --accent-rgb override)
titleBg?: string // Y2K titlebar gradient
bodyBg?: string // Y2K body gradient
}
const PALETTES: Record<OverlayStyle, OverlayPalette[]> = {
classic: [
{
id: 'default', name: 'Тёмный', swatches: ['#0a0a10', '#ffffff', '#888888'],
bg: 'rgba(10,10,16,0.82)', border: '1px solid rgba(255,255,255,0.09)',
shadow: '0 8px 32px rgba(0,0,0,0.55)',
text: '#ffffff', text2: 'rgba(255,255,255,0.45)', chroma: 'rgba(255,255,255,0.06)',
},
{
id: 'warm', name: 'Тёплый', swatches: ['#160c06', '#ffeedd', '#dca850'],
bg: 'rgba(22,12,6,0.86)', border: '1px solid rgba(220,168,80,0.20)',
shadow: '0 8px 32px rgba(20,8,0,0.6)',
text: '#ffeedd', text2: 'rgba(255,200,120,0.50)', chroma: 'rgba(220,168,80,0.10)',
},
{
id: 'ocean', name: 'Морской', swatches: ['#040a1a', '#ddeeff', '#3c82ff'],
bg: 'rgba(4,10,26,0.88)', border: '1px solid rgba(60,130,255,0.22)',
shadow: '0 8px 32px rgba(0,10,40,0.6)',
text: '#ddeeff', text2: 'rgba(100,170,255,0.50)', chroma: 'rgba(60,130,255,0.08)',
},
{
id: 'frost', name: 'Белый', swatches: ['#ffffff', '#111111', '#bbbbbb'],
bg: 'rgba(255,255,255,0.90)', border: '1px solid rgba(0,0,0,0.09)',
shadow: '0 8px 32px rgba(0,0,0,0.15)',
text: '#111111', text2: 'rgba(0,0,0,0.42)', chroma: 'rgba(0,0,0,0.05)',
},
],
aero: [
{
id: 'default', name: 'Небо', swatches: ['#bee6ff', '#78b9ff', '#003a6e'],
bg: 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#003a6e', text2: 'rgba(0,60,130,0.60)',
},
{
id: 'rose', name: 'Роза', swatches: ['#ffc8d7', '#ff87af', '#6e0030'],
bg: 'linear-gradient(160deg,rgba(255,200,215,0.55) 0%,rgba(255,135,175,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(255,100,160,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#6e0030', text2: 'rgba(130,0,60,0.60)',
},
{
id: 'mint', name: 'Мята', swatches: ['#b4ffd2', '#50dc9b', '#003a20'],
bg: 'linear-gradient(160deg,rgba(180,255,210,0.55) 0%,rgba(80,220,155,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(60,200,130,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#003a20', text2: 'rgba(0,80,50,0.60)',
},
{
id: 'lavender', name: 'Лаванда', swatches: ['#dcc8ff', '#9b7dff', '#3a006e'],
bg: 'linear-gradient(160deg,rgba(220,200,255,0.55) 0%,rgba(155,125,255,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(130,80,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#3a006e', text2: 'rgba(70,0,140,0.55)',
},
],
retro: [
{
id: 'default', name: 'Янтарь', swatches: ['#0c0500', '#c07030', '#f8d090'],
bg: 'rgba(12,5,0,0.94)', border: '2px solid #c07030',
shadow: '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#f8d090', text2: '#9a6020', chroma: 'rgba(180,100,20,0.30)',
},
{
id: 'phosphor', name: 'Фосфор', swatches: ['#000c04', '#30c060', '#90f8b0'],
bg: 'rgba(0,12,4,0.94)', border: '2px solid #30c060',
shadow: '0 0 28px rgba(20,160,60,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#90f8b0', text2: '#207840', chroma: 'rgba(20,160,60,0.28)',
},
{
id: 'crt', name: 'CRT синий', swatches: ['#000412', '#3060c0', '#90b8f8'],
bg: 'rgba(0,4,18,0.94)', border: '2px solid #3060c0',
shadow: '0 0 28px rgba(30,80,200,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#90b8f8', text2: '#204880', chroma: 'rgba(30,80,200,0.30)',
},
{
id: 'blood', name: 'Красный', swatches: ['#0e0202', '#c02828', '#f89090'],
bg: 'rgba(14,2,2,0.94)', border: '2px solid #c02828',
shadow: '0 0 28px rgba(180,28,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#f89090', text2: '#802020', chroma: 'rgba(180,28,20,0.28)',
},
],
neon: [
{
id: 'default', name: 'Акцент', swatches: ['#00000a', 'var(--accent)', 'var(--accent)'],
bg: 'rgba(0,0,10,0.90)',
},
{
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
bg: 'rgba(0,4,12,0.92)', chroma: '#00e5ff', chroma2: '0,229,255',
},
{
id: 'magenta', name: 'Пурпур', swatches: ['#0a0008', '#ff00cc', '#ff00cc'],
bg: 'rgba(10,0,8,0.92)', chroma: '#ff00cc', chroma2: '255,0,204',
},
{
id: 'lime', name: 'Лайм', swatches: ['#000a04', '#00ff88', '#00ff88'],
bg: 'rgba(0,10,4,0.92)', chroma: '#00ff88', chroma2: '0,255,136',
},
],
clean: [
{
id: 'default', name: 'Стандарт', swatches: ['transparent', '#ffffff', 'var(--accent)'],
},
{
id: 'shadow', name: 'С тенью', swatches: ['transparent', '#ffffff', '#666666'],
text: '#ffffff',
},
{
id: 'dark-text', name: 'Тёмный', swatches: ['transparent', '#111111', '#333333'],
text: '#111111', text2: 'rgba(0,0,0,0.50)',
},
{
id: 'warm-text', name: 'Тёплый', swatches: ['transparent', '#ffeedd', '#ddaa66'],
text: '#ffeedd', text2: 'rgba(255,200,120,0.60)',
},
],
y2k: [
{
id: 'default', name: 'Лунный', swatches: ['#c8d0e0', '#1a4090', '#000060'],
titleBg: 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)',
bodyBg: 'linear-gradient(160deg,#dce4f0,#c0c8e0)',
text: '#000060', text2: '#404880',
},
{
id: 'rose', name: 'Розовый', swatches: ['#f0d4dc', '#901440', '#600020'],
titleBg: 'linear-gradient(90deg,#901440 0%,#d04080 40%,#901440 100%)',
bodyBg: 'linear-gradient(160deg,#f0d4dc,#d0b0c0)',
text: '#600020', text2: '#804060',
},
{
id: 'dark', name: 'Тёмный', swatches: ['#c0c0c8', '#484848', '#000020'],
titleBg: 'linear-gradient(90deg,#282828 0%,#484848 40%,#282828 100%)',
bodyBg: 'linear-gradient(160deg,#c0c0c8,#a0a0b0)',
text: '#000020', text2: '#303040',
},
{
id: 'forest', name: 'Лесной', swatches: ['#d4ecd8', '#106020', '#004010'],
titleBg: 'linear-gradient(90deg,#106020 0%,#308040 40%,#106020 100%)',
bodyBg: 'linear-gradient(160deg,#d4ecd8,#b8d8c0)',
text: '#004010', text2: '#306040',
},
],
lofi: [
{
id: 'default', name: 'Коричневый', swatches: ['#2a1a0e', '#fde8c0', '#e8a060'],
bg: 'rgba(42,26,14,0.92)',
text: '#fde8c0', text2: 'rgba(var(--accent-rgb),0.60)',
},
{
id: 'night', name: 'Ночной', swatches: ['#0c1020', '#c0d4f8', '#7090d8'],
bg: 'rgba(12,16,32,0.92)',
text: '#c0d4f8', text2: 'rgba(var(--accent-rgb),0.60)',
},
{
id: 'forest', name: 'Лесной', swatches: ['#0c1c10', '#c8f0c8', '#60c870'],
bg: 'rgba(12,28,16,0.92)',
text: '#c8f0c8', text2: 'rgba(var(--accent-rgb),0.60)',
},
{
id: 'plum', name: 'Сливовый', swatches: ['#1c0c20', '#e8c0f8', '#c060d8'],
bg: 'rgba(28,12,32,0.92)',
text: '#e8c0f8', text2: 'rgba(var(--accent-rgb),0.60)',
},
],
glam: [
{
id: 'default', name: 'Золото', swatches: ['#1c1204', '#d4af37', '#f0c840'],
bg: 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))',
chroma: '212,175,55', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
},
{
id: 'silver', name: 'Серебро', swatches: ['#101214', '#c0c8d2', '#e0e8f0'],
bg: 'linear-gradient(145deg,rgba(16,18,20,0.96),rgba(10,12,14,0.96))',
chroma: '192,200,210', text: '#e0e8f0', text2: 'rgba(160,170,180,0.65)',
},
{
id: 'rose-gold', name: 'Розовое золото', swatches: ['#160e0e', '#dc9e8e', '#f0c8b8'],
bg: 'linear-gradient(145deg,rgba(22,14,14,0.95),rgba(14,8,8,0.95))',
chroma: '220,158,142', text: '#f0c8b8', text2: 'rgba(200,140,120,0.65)',
},
{
id: 'emerald', name: 'Изумруд', swatches: ['#08140e', '#32b478', '#80f8c0'],
bg: 'linear-gradient(145deg,rgba(8,20,14,0.95),rgba(4,12,8,0.95))',
chroma: '50,180,120', text: '#80f8c0', text2: 'rgba(40,160,100,0.65)',
},
],
matrix: [
{
id: 'default', name: 'Зелёный', swatches: ['#000800', '#00ff32', '#00ff32'],
bg: 'rgba(0,8,0,0.93)', chroma: '#00ff32', chroma2: '0,255,50',
border: '1px solid rgba(0,255,50,0.35)',
},
{
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
bg: 'rgba(0,4,12,0.93)', chroma: '#00e5ff', chroma2: '0,229,255',
border: '1px solid rgba(0,229,255,0.35)',
},
{
id: 'amber', name: 'Янтарь', swatches: ['#0c0800', '#ffb400', '#ffb400'],
bg: 'rgba(12,8,0,0.93)', chroma: '#ffb400', chroma2: '255,180,0',
border: '1px solid rgba(255,180,0,0.35)',
},
{
id: 'violet', name: 'Пурпур', swatches: ['#08000c', '#cc00ff', '#cc00ff'],
bg: 'rgba(8,0,12,0.93)', chroma: '#cc00ff', chroma2: '204,0,255',
border: '1px solid rgba(204,0,255,0.35)',
},
],
}
export function getPalettes(style: OverlayStyle): OverlayPalette[] {
return PALETTES[style] ?? PALETTES.classic
}
export function getPalette(style: OverlayStyle, paletteId: string): OverlayPalette {
const list = getPalettes(style)
return list.find(p => p.id === paletteId) ?? list[0]
}
// ── Custom palette ────────────────────────────────────────────────────────────
export interface ColorFieldDef {
key: string
label: string
default: string // hex default for the color picker
}
export const STYLE_CUSTOM_FIELDS: Record<OverlayStyle, ColorFieldDef[]> = {
classic: [
{ key: 'bg', label: 'Фон', default: '#0a0a10' },
{ key: 'chroma', label: 'Рамка', default: '#ffffff' },
{ key: 'text', label: 'Текст', default: '#ffffff' },
{ key: 'text2', label: 'Текст 2', default: '#888888' },
],
aero: [
{ key: 'bg', label: 'Цвет', default: '#bee6ff' },
{ key: 'text', label: 'Текст', default: '#003a6e' },
{ key: 'text2', label: 'Текст 2', default: '#004488' },
],
retro: [
{ key: 'bg', label: 'Фон', default: '#0c0500' },
{ key: 'chroma', label: 'Хром', default: '#c07030' },
{ key: 'text', label: 'Текст', default: '#f8d090' },
{ key: 'text2', label: 'Текст 2', default: '#9a6020' },
],
neon: [
{ key: 'bg', label: 'Фон', default: '#00000a' },
{ key: 'chroma', label: 'Свечение', default: '#de9cfe' },
],
clean: [
{ key: 'text', label: 'Текст', default: '#ffffff' },
{ key: 'text2', label: 'Текст 2', default: '#888888' },
],
y2k: [
{ key: 'titleBg', label: 'Заголовок', default: '#1a4090' },
{ key: 'bodyBg', label: 'Тело', default: '#c0c8e0' },
{ key: 'text', label: 'Текст', default: '#000060' },
{ key: 'text2', label: 'Текст 2', default: '#404880' },
],
lofi: [
{ key: 'bg', label: 'Фон', default: '#2a1a0e' },
{ key: 'text', label: 'Текст', default: '#fde8c0' },
{ key: 'text2', label: 'Текст 2', default: '#e8a060' },
],
glam: [
{ key: 'bg', label: 'Фон', default: '#1c1204' },
{ key: 'chroma', label: 'Металл', default: '#d4af37' },
{ key: 'text', label: 'Текст', default: '#f0c840' },
],
matrix: [
{ key: 'bg', label: 'Фон', default: '#000800' },
{ key: 'chroma', label: 'Фосфор', default: '#00ff32' },
],
}
function hexToRgba(hex: string, a: number): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return hex
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${a})`
}
function hexToRgb(hex: string): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return '128,128,128'
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `${r},${g},${b}`
}
export function buildCustomPalette(
style: OverlayStyle,
data: Record<string, string>,
): OverlayPalette {
const pal: OverlayPalette = { id: 'custom', name: 'Свой', swatches: [] }
if (data.text) pal.text = data.text
if (data.text2) pal.text2 = data.text2
if (data.bg) {
if (style === 'aero') {
pal.bg = `linear-gradient(160deg,${hexToRgba(data.bg, 0.55)} 0%,${hexToRgba(data.bg, 0.38)} 100%)`
} else {
const alpha = style === 'classic' ? 0.85 : 0.92
pal.bg = hexToRgba(data.bg, alpha)
}
}
if (data.chroma) {
switch (style) {
case 'glam':
pal.chroma = hexToRgb(data.chroma)
break
case 'neon':
case 'matrix':
pal.chroma = data.chroma
pal.chroma2 = hexToRgb(data.chroma)
if (style === 'matrix') {
pal.border = `1px solid ${hexToRgba(data.chroma, 0.38)}`
}
break
case 'retro':
pal.chroma = hexToRgba(data.chroma, 0.30)
pal.border = `2px solid ${data.chroma}`
pal.shadow = `0 0 28px ${hexToRgba(data.chroma, 0.45)},4px 4px 0 rgba(0,0,0,0.6)`
break
case 'classic':
pal.border = `1px solid ${hexToRgba(data.chroma, 0.28)}`
pal.chroma = hexToRgba(data.chroma, 0.10)
break
default:
pal.chroma = data.chroma
}
}
if (data.titleBg) pal.titleBg = data.titleBg
if (data.bodyBg) pal.bodyBg = data.bodyBg
return pal
}

View File

@@ -2,49 +2,53 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { User } from '@/types' import type { User } from '@/types'
import { fetchMe } from '@/lib/authApi' import { fetchMe, logout } from '@/lib/authApi'
const TOKEN_KEY = 'pm_token'
const USER_KEY = 'pm_user' const USER_KEY = 'pm_user'
interface AuthStore { interface AuthStore {
token: string | null
user: User | null user: User | null
setAuth: (token: string, user: User) => void setAuth: (user: User) => void
clearAuth: () => void clearAuth: () => void
hydrate: () => Promise<void> hydrate: () => Promise<void>
} }
export const useAuthStore = create<AuthStore>((set, get) => ({ export const useAuthStore = create<AuthStore>((set) => ({
token: null,
user: null, user: null,
setAuth: (token, user) => { setAuth: (user) => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user)) localStorage.setItem(USER_KEY, JSON.stringify(user))
} }
set({ token, user }) set({ user })
}, },
clearAuth: () => { clearAuth: async () => {
await logout()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY) localStorage.removeItem(USER_KEY)
// Remove legacy token key if present
localStorage.removeItem('pm_token')
} }
set({ token: null, user: null }) set({ user: null })
}, },
hydrate: async () => { hydrate: async () => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const token = localStorage.getItem(TOKEN_KEY) // Optimistically restore user from cache for instant UI
if (!token) return const cached = localStorage.getItem(USER_KEY)
if (cached) {
try { set({ user: JSON.parse(cached) }) } catch {}
}
// Verify with server via cookie
try { try {
const user = await fetchMe(token) const user = await fetchMe()
set({ token, user }) localStorage.setItem(USER_KEY, JSON.stringify(user))
set({ user })
} catch { } catch {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY) localStorage.removeItem(USER_KEY)
localStorage.removeItem('pm_token')
set({ user: null })
} }
}, },
})) }))

View File

@@ -18,23 +18,50 @@ export const BG_PRESETS: BgPreset[] = [
{ id: 'none', name: 'Нет', desc: 'Чистый фон' }, { id: 'none', name: 'Нет', desc: 'Чистый фон' },
] ]
export interface RaysConfig { export interface OrbsConfig { brightness: number; speed: number; trail: number }
count: number // 416 export interface WavesConfig { amplitude: number; speed: number; trail: number }
speed: number // 0.23.0 (rotation speed multiplier) export interface ParticlesConfig { speed: number; linkDist: number; trail: number }
brightness: number // 0.32.5 export interface AuroraConfig { brightness: number; speed: number; trail: number }
spread: number // 0.32.0 (ray width multiplier) export interface PulseConfig { sensitivity: number; ringSpeed: number; trail: number }
export interface StarsConfig { brightness: number; twinkle: number; trail: number }
export interface RainConfig { drops: number; speed: number; trail: number }
export interface RaysConfig { count: number; speed: number; brightness: number; spread: number; trail: number }
export interface FxConfigs {
orbs: OrbsConfig
waves: WavesConfig
particles: ParticlesConfig
aurora: AuroraConfig
pulse: PulseConfig
stars: StarsConfig
rain: RainConfig
rays: RaysConfig
} }
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1 } export const DEFAULT_ORBS: OrbsConfig = { brightness: 1, speed: 1, trail: 0 }
export const DEFAULT_WAVES: WavesConfig = { amplitude: 1, speed: 1, trail: 0 }
export const DEFAULT_PARTICLES: ParticlesConfig = { speed: 1, linkDist: 1, trail: 0 }
export const DEFAULT_AURORA: AuroraConfig = { brightness: 1, speed: 1, trail: 0 }
export const DEFAULT_PULSE: PulseConfig = { sensitivity: 0.5, ringSpeed: 1, trail: 0 }
export const DEFAULT_STARS: StarsConfig = { brightness: 1, twinkle: 1, trail: 0 }
export const DEFAULT_RAIN: RainConfig = { drops: 30, speed: 1, trail: 0 }
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1, trail: 0 }
const KEY_BG = 'pm_bg' export const DEFAULT_FX: FxConfigs = {
const KEY_RAYS = 'pm_rays' orbs: DEFAULT_ORBS, waves: DEFAULT_WAVES, particles: DEFAULT_PARTICLES,
aurora: DEFAULT_AURORA, pulse: DEFAULT_PULSE, stars: DEFAULT_STARS,
rain: DEFAULT_RAIN, rays: DEFAULT_RAYS,
}
const KEY_BG = 'pm_bg'
const KEY_FX = 'pm_fx'
interface BgStore { interface BgStore {
bgMode: BgMode bgMode: BgMode
raysConfig: RaysConfig fxConfigs: FxConfigs
setBg: (mode: BgMode) => void setBg: (mode: BgMode) => void
setRaysConfig: (cfg: Partial<RaysConfig>) => void setFxConfig: <M extends keyof FxConfigs>(mode: M, cfg: Partial<FxConfigs[M]>) => void
resetFx: (mode: keyof FxConfigs) => void
} }
export const useBgStore = create<BgStore>((set, get) => ({ export const useBgStore = create<BgStore>((set, get) => ({
@@ -43,18 +70,34 @@ export const useBgStore = create<BgStore>((set, get) => ({
const saved = localStorage.getItem(KEY_BG) as BgMode | null const saved = localStorage.getItem(KEY_BG) as BgMode | null
return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs' return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs'
})(), })(),
raysConfig: (() => { fxConfigs: (() => {
if (typeof window === 'undefined') return DEFAULT_RAYS if (typeof window === 'undefined') return DEFAULT_FX
try { return { ...DEFAULT_RAYS, ...JSON.parse(localStorage.getItem(KEY_RAYS) || '{}') } } try {
catch { return DEFAULT_RAYS } const saved = JSON.parse(localStorage.getItem(KEY_FX) || '{}')
return {
orbs: { ...DEFAULT_ORBS, ...(saved.orbs ?? {}) },
waves: { ...DEFAULT_WAVES, ...(saved.waves ?? {}) },
particles: { ...DEFAULT_PARTICLES, ...(saved.particles ?? {}) },
aurora: { ...DEFAULT_AURORA, ...(saved.aurora ?? {}) },
pulse: { ...DEFAULT_PULSE, ...(saved.pulse ?? {}) },
stars: { ...DEFAULT_STARS, ...(saved.stars ?? {}) },
rain: { ...DEFAULT_RAIN, ...(saved.rain ?? {}) },
rays: { ...DEFAULT_RAYS, ...(saved.rays ?? {}) },
}
} catch { return DEFAULT_FX }
})(), })(),
setBg: (mode) => { setBg: (mode) => {
if (typeof window !== 'undefined') localStorage.setItem(KEY_BG, mode) if (typeof window !== 'undefined') localStorage.setItem(KEY_BG, mode)
set({ bgMode: mode }) set({ bgMode: mode })
}, },
setRaysConfig: (cfg) => { setFxConfig: (mode, cfg) => {
const next = { ...get().raysConfig, ...cfg } const next: FxConfigs = { ...get().fxConfigs, [mode]: { ...get().fxConfigs[mode], ...cfg } }
if (typeof window !== 'undefined') localStorage.setItem(KEY_RAYS, JSON.stringify(next)) if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
set({ raysConfig: next }) set({ fxConfigs: next })
},
resetFx: (mode) => {
const next: FxConfigs = { ...get().fxConfigs, [mode]: DEFAULT_FX[mode] }
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
set({ fxConfigs: next })
}, },
})) }))

View File

@@ -0,0 +1,215 @@
'use client'
import { create } from 'zustand'
const KEY_ENABLED = 'pm_overlay_enabled'
const KEY_DESIGN = 'pm_overlay_design'
const KEY_STYLE = 'pm_overlay_style'
const KEY_ACCENT = 'pm_overlay_accent'
const KEY_POSITION = 'pm_overlay_position'
const KEY_FONT = 'pm_overlay_font'
const KEY_TEXTCOLOR = 'pm_overlay_textcolor'
const KEY_SHOWCVR = 'pm_overlay_showcvr'
const KEY_SHOWEQ = 'pm_overlay_showeq'
const KEY_PALETTE = 'pm_overlay_palette'
const KEY_CUSTPALETTES = 'pm_overlay_custpalettes'
const KEY_MARGIN = 'pm_overlay_margin'
const KEY_SCALE = 'pm_overlay_scale'
const KEY_OPACITY = 'pm_overlay_opacity'
export type OverlayDesign = 'minimal' | 'card' | 'bar'
export type OverlayStyle =
| 'classic' | 'aero' | 'retro' | 'neon' | 'clean'
| 'y2k' | 'lofi' | 'glam' | 'matrix'
export type OverlayPosition = 'bl' | 'br' | 'tl' | 'tr'
export interface StyleConfig {
bg: string
blur: string
border: string
shadow: string
radius: string
text: string
text2: string
eqColor: string
fontStyle: 'normal' | 'italic'
fontWeight: number
letterSpacing: string
}
export const OVERLAY_STYLES: Record<OverlayStyle, { name: string; desc: string; cfg: StyleConfig }> = {
classic: {
name: 'Классика', desc: 'Тёмное стекло, нейтральный',
cfg: {
bg: 'rgba(10,10,15,0.82)', blur: '20px',
border: '1px solid rgba(255,255,255,0.08)',
shadow: '0 8px 32px rgba(0,0,0,0.5)',
radius: '16px', text: '#ffffff', text2: 'rgba(255,255,255,0.45)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
},
},
aero: {
name: 'Фрутигер Аеро', desc: 'Глянцевый XP-стиль',
cfg: {
bg: 'linear-gradient(145deg,rgba(200,235,255,0.55) 0%,rgba(140,195,255,0.4) 100%)',
blur: '24px',
border: '1.5px solid rgba(255,255,255,0.75)',
shadow: '0 4px 20px rgba(80,160,255,0.35),inset 0 1px 0 rgba(255,255,255,0.6)',
radius: '22px', text: '#003a6e', text2: 'rgba(0,60,120,0.6)',
eqColor: '#0077cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.02em',
},
},
retro: {
name: 'Ретро', desc: 'VHS / кассетная эстетика',
cfg: {
bg: 'rgba(18,8,2,0.93)',
blur: '0px',
border: '2px solid #b06828',
shadow: '0 0 24px rgba(180,100,20,0.45),4px 4px 0 rgba(0,0,0,0.5)',
radius: '4px', text: '#f8d090', text2: '#9a6020',
eqColor: '#e08030', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.05em',
},
},
neon: {
name: 'Неон', desc: 'Киберпанк, светящиеся линии',
cfg: {
bg: 'rgba(0,0,10,0.9)',
blur: '8px',
border: '1px solid rgba(var(--accent-rgb),0.55)',
shadow: '0 0 24px rgba(var(--accent-rgb),0.35),inset 0 0 16px rgba(var(--accent-rgb),0.06)',
radius: '8px', text: 'var(--accent)', text2: 'rgba(var(--accent-rgb),0.55)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 800, letterSpacing: '0.04em',
},
},
clean: {
name: 'Минимализм', desc: 'Только текст, без фона',
cfg: {
bg: 'transparent',
blur: '0px',
border: 'none',
shadow: 'none',
radius: '0', text: '#ffffff', text2: 'rgba(255,255,255,0.5)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
},
},
y2k: {
name: 'Y2K', desc: 'Хром, ранние 2000-е',
cfg: {
bg: 'linear-gradient(160deg,#d8d8e8 0%,#b0b8d0 50%,#c8cce0 100%)',
blur: '4px',
border: '2px solid rgba(255,255,255,0.9)',
shadow: '3px 3px 0 rgba(0,0,80,0.25),inset 0 1px 0 rgba(255,255,255,0.8)',
radius: '6px', text: '#000060', text2: '#444488',
eqColor: '#0044cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.01em',
},
},
lofi: {
name: 'Ло-фай', desc: 'Тёплые тона, уютная атмосфера',
cfg: {
bg: 'rgba(38,24,12,0.9)',
blur: '14px',
border: '1px solid rgba(240,180,100,0.18)',
shadow: '0 6px 28px rgba(0,0,0,0.45)',
radius: '14px', text: '#fde8c0', text2: 'rgba(240,180,100,0.55)',
eqColor: '#e8a060', fontStyle: 'italic', fontWeight: 500, letterSpacing: '0.01em',
},
},
glam: {
name: 'Гламур', desc: 'Золото, роскошь',
cfg: {
bg: 'linear-gradient(145deg,rgba(28,18,4,0.93) 0%,rgba(18,12,2,0.93) 100%)',
blur: '16px',
border: '1px solid rgba(212,175,55,0.45)',
shadow: '0 8px 32px rgba(0,0,0,0.65),0 0 0 1px rgba(212,175,55,0.2)',
radius: '14px', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
eqColor: '#d4af37', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.02em',
},
},
matrix: {
name: 'Матрица', desc: 'Зелёный терминал, хакер',
cfg: {
bg: 'rgba(0,8,0,0.92)',
blur: '4px',
border: '1px solid rgba(0,255,50,0.3)',
shadow: '0 0 20px rgba(0,255,50,0.18)',
radius: '4px', text: '#00ff32', text2: 'rgba(0,200,30,0.55)',
eqColor: '#00ff32', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.08em',
},
},
}
interface OverlayStore {
enabled: boolean
design: OverlayDesign
style: OverlayStyle
accentColor: string
position: OverlayPosition
font: string
textColor: string
showCover: boolean
showEq: boolean
palette: string
customPalettes: Record<string, Record<string, string>>
margin: number
scale: number
opacity: number
setEnabled: (v: boolean) => void
setDesign: (d: OverlayDesign) => void
setStyle: (s: OverlayStyle) => void
setAccentColor: (c: string) => void
setPosition: (p: OverlayPosition) => void
setFont: (f: string) => void
setTextColor: (c: string) => void
setShowCover: (v: boolean) => void
setShowEq: (v: boolean) => void
setPalette: (p: string) => void
setCustomPaletteField: (style: string, field: string, value: string) => void
setMargin: (v: number) => void
setScale: (v: number) => void
setOpacity: (v: number) => void
}
const ls = (key: string) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null)
const lsSet = (key: string, v: string) => { if (typeof window !== 'undefined') localStorage.setItem(key, v) }
export const useOverlayStore = create<OverlayStore>((set) => ({
enabled: ls(KEY_ENABLED) !== 'false',
design: (ls(KEY_DESIGN) ?? 'minimal') as OverlayDesign,
style: (ls(KEY_STYLE) ?? 'classic') as OverlayStyle,
accentColor: ls(KEY_ACCENT) ?? '#de9cfe',
position: (ls(KEY_POSITION) ?? 'bl') as OverlayPosition,
font: ls(KEY_FONT) ?? '',
textColor: ls(KEY_TEXTCOLOR) ?? '',
showCover: ls(KEY_SHOWCVR) !== 'false',
showEq: ls(KEY_SHOWEQ) !== 'false',
palette: ls(KEY_PALETTE) ?? 'default',
customPalettes: (() => { try { return JSON.parse(ls(KEY_CUSTPALETTES) ?? '{}') } catch { return {} } })(),
margin: Number(ls(KEY_MARGIN) ?? 24),
scale: Number(ls(KEY_SCALE) ?? 1),
opacity: Number(ls(KEY_OPACITY) ?? 1),
setEnabled: (v) => { lsSet(KEY_ENABLED, String(v)); set({ enabled: v }) },
setDesign: (d) => { lsSet(KEY_DESIGN, d); set({ design: d }) },
setStyle: (s) => { lsSet(KEY_STYLE, s); set({ style: s }) },
setAccentColor: (c) => { lsSet(KEY_ACCENT, c); set({ accentColor: c }) },
setPosition: (p) => { lsSet(KEY_POSITION, p); set({ position: p }) },
setFont: (f) => { lsSet(KEY_FONT, f); set({ font: f }) },
setTextColor: (c) => { lsSet(KEY_TEXTCOLOR, c); set({ textColor: c }) },
setShowCover: (v) => { lsSet(KEY_SHOWCVR, String(v)); set({ showCover: v }) },
setShowEq: (v) => { lsSet(KEY_SHOWEQ, String(v)); set({ showEq: v }) },
setPalette: (p) => { lsSet(KEY_PALETTE, p); set({ palette: p }) },
setCustomPaletteField: (style, field, value) => {
set((state) => {
const next = {
...state.customPalettes,
[style]: { ...(state.customPalettes[style] ?? {}), [field]: value },
}
lsSet(KEY_CUSTPALETTES, JSON.stringify(next))
return { customPalettes: next }
})
},
setMargin: (v) => { lsSet(KEY_MARGIN, String(v)); set({ margin: v }) },
setScale: (v) => { lsSet(KEY_SCALE, String(v)); set({ scale: v }) },
setOpacity: (v) => { lsSet(KEY_OPACITY, String(v)); set({ opacity: v }) },
}))

View File

@@ -1,18 +1,25 @@
'use client' 'use client'
import { create } from 'zustand' import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types' import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types'
import { COLORS } from '@/lib/colors' import { COLORS } from '@/lib/colors'
import { fairShuffle, randomShuffle } from '@/lib/shuffle' import { fairShuffle, randomShuffle } from '@/lib/shuffle'
const HISTORY_LIMIT = 100
export type RepeatMode = 'none' | 'one' | 'all'
interface PartyStore { interface PartyStore {
people: Person[] people: Person[]
queue: QueueItem[] queue: QueueItem[]
curIdx: number curIdx: number
loadKey: number loadKey: number
shuffleMode: ShuffleMode shuffleMode: ShuffleMode
repeatMode: RepeatMode
history: HistoryEntry[] history: HistoryEntry[]
currentResults: SearchResult[] currentResults: SearchResult[]
currentResult: SearchResult | null
searchStatus: 'idle' | 'searching' | 'not-found' searchStatus: 'idle' | 'searching' | 'not-found'
addPerson: (name: string, tracks: string[]) => void addPerson: (name: string, tracks: string[]) => void
@@ -27,174 +34,195 @@ interface PartyStore {
updateQueueItemImg: (idx: number, img: string) => void updateQueueItemImg: (idx: number, img: string) => void
reorderQueue: (fromIdx: number, toIdx: number) => void reorderQueue: (fromIdx: number, toIdx: number) => void
setCurrentResults: (results: SearchResult[]) => void setCurrentResults: (results: SearchResult[]) => void
setCurrentResult: (r: SearchResult | null) => void
setSearchStatus: (status: PartyStore['searchStatus']) => void setSearchStatus: (status: PartyStore['searchStatus']) => void
setShuffleMode: (mode: ShuffleMode) => void setShuffleMode: (mode: ShuffleMode) => void
setRepeatMode: (mode: RepeatMode) => void
addToHistory: (entry: HistoryEntry) => void addToHistory: (entry: HistoryEntry) => void
clearHistory: () => void clearHistory: () => void
} }
export const usePartyStore = create<PartyStore>((set, get) => ({ export const usePartyStore = create<PartyStore>()(
people: [], persist(
queue: [], (set, get) => ({
curIdx: -1, people: [],
loadKey: 0, queue: [],
shuffleMode: 'fair', curIdx: -1,
history: [], loadKey: 0,
currentResults: [], shuffleMode: 'fair',
searchStatus: 'idle', repeatMode: 'none',
history: [],
currentResults: [],
currentResult: null,
searchStatus: 'idle',
addPerson: (name, tracks) => { addPerson: (name, tracks) => {
set((state) => { set((state) => {
const pi = state.people.length const pi = state.people.length
return { return {
people: [ people: [
...state.people, ...state.people,
{ {
name, name,
color: COLORS[pi % COLORS.length], color: COLORS[pi % COLORS.length],
tracks: tracks.map((t) => ({ title: t })), tracks: tracks.map((t) => ({ title: t })),
}, },
], ],
} }
}) })
}, },
removePerson: (index) => { removePerson: (index) => {
set((state) => ({ set((state) => ({
people: state.people people: state.people
.filter((_, i) => i !== index) .filter((_, i) => i !== index)
.map((p, i) => ({ ...p, color: COLORS[i % COLORS.length] })), .map((p, i) => ({ ...p, color: COLORS[i % COLORS.length] })),
})) }))
}, },
generateMix: () => { generateMix: () => {
const { people, shuffleMode } = get() const { people, shuffleMode } = get()
if (!people.length) return if (!people.length) return
const queue = shuffleMode === 'fair' ? fairShuffle(people) : randomShuffle(people) const queue = shuffleMode === 'fair' ? fairShuffle(people) : randomShuffle(people)
set((state) => ({ queue, curIdx: 0, loadKey: state.loadKey + 1 })) set((state) => ({ queue, curIdx: 0, loadKey: state.loadKey + 1 }))
}, },
addFairToQueue: (owner, color, tracks) => { addFairToQueue: (owner, color, tracks) => {
const { queue, curIdx } = get() const { queue, curIdx } = get()
const played = queue.slice(0, Math.max(curIdx + 1, 0)) const played = queue.slice(0, Math.max(curIdx + 1, 0))
const remaining = queue.slice(Math.max(curIdx + 1, 0)) const remaining = queue.slice(Math.max(curIdx + 1, 0))
// Group remaining by owner, preserving insertion order const ownerOrder: string[] = []
const ownerOrder: string[] = [] const groups = new Map<string, QueueItem[]>()
const groups = new Map<string, QueueItem[]>() for (const item of remaining) {
for (const item of remaining) { if (!groups.has(item.owner)) {
if (!groups.has(item.owner)) { groups.set(item.owner, [])
groups.set(item.owner, []) ownerOrder.push(item.owner)
ownerOrder.push(item.owner) }
} groups.get(item.owner)!.push(item)
groups.get(item.owner)!.push(item) }
const newItems: QueueItem[] = tracks.map((title, ti) => ({
title, owner, color, _pi: 0, _ti: ti, img: '',
}))
if (!groups.has(owner)) {
groups.set(owner, [])
ownerOrder.push(owner)
}
groups.get(owner)!.push(...newItems)
const groupArrays = ownerOrder.map(o => groups.get(o)!)
const maxLen = Math.max(...groupArrays.map(g => g.length))
const interleaved: QueueItem[] = []
for (let i = 0; i < maxLen; i++) {
for (const group of groupArrays) {
if (i < group.length) interleaved.push(group[i])
}
}
const newQueue = [...played, ...interleaved]
const newCurIdx = curIdx < 0 ? 0 : curIdx
set((state) => ({
queue: newQueue,
curIdx: newCurIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
loadPlaylist: (tracks: string[]) => {
const color = COLORS[0]
const queue: QueueItem[] = tracks.map((title, ti) => ({
title,
owner: 'Соло',
color,
_pi: 0,
_ti: ti,
img: '',
}))
set((state) => ({
people: [{ name: 'Соло', color, tracks: tracks.map((t) => ({ title: t })) }],
queue,
curIdx: 0,
loadKey: state.loadKey + 1,
}))
},
removeFromQueue: (idx) => {
set((state) => {
const queue = state.queue.filter((_, i) => i !== idx)
let curIdx = state.curIdx
if (idx < curIdx) curIdx--
else if (idx === curIdx) curIdx = Math.min(curIdx, queue.length - 1)
return { queue, curIdx }
})
},
addTrackToQueue: (title) => {
const { curIdx } = get()
const color = COLORS[0]
const newItem: QueueItem = { title, owner: 'Remote', color, _pi: 0, _ti: 0, img: '' }
set((state) => ({
queue: [...state.queue, newItem],
curIdx: curIdx < 0 ? 0 : state.curIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
setCurIdx: (idx) => {
set((state) => ({ curIdx: idx, loadKey: state.loadKey + 1 }))
},
setQueue: (queue) => set({ queue }),
updateQueueItemImg: (idx, img) => {
set((state) => {
const queue = [...state.queue]
if (queue[idx]) queue[idx] = { ...queue[idx], img }
return { queue }
})
},
reorderQueue: (fromIdx, toIdx) => {
set((state) => {
const queue = [...state.queue]
const [moved] = queue.splice(fromIdx, 1)
queue.splice(toIdx, 0, moved)
let { curIdx } = state
if (curIdx === fromIdx) curIdx = toIdx
else if (fromIdx < curIdx && toIdx >= curIdx) curIdx--
else if (fromIdx > curIdx && toIdx <= curIdx) curIdx++
return { queue, curIdx }
})
},
setCurrentResults: (results) => set({ currentResults: results }),
setCurrentResult: (r) => set({ currentResult: r }),
setSearchStatus: (searchStatus) => set({ searchStatus }),
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
setRepeatMode: (repeatMode) => set({ repeatMode }),
addToHistory: (entry) => {
set((state) => ({
history: [entry, ...state.history].slice(0, HISTORY_LIMIT),
}))
},
clearHistory: () => set({ history: [] }),
}),
{
name: 'pm_party',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
people: state.people,
queue: state.queue,
curIdx: state.curIdx,
shuffleMode: state.shuffleMode,
repeatMode: state.repeatMode,
history: state.history,
}),
} }
)
// Add new tracks to owner's group )
const newItems: QueueItem[] = tracks.map((title, ti) => ({
title, owner, color, _pi: 0, _ti: ti, img: '',
}))
if (!groups.has(owner)) {
groups.set(owner, [])
ownerOrder.push(owner)
}
groups.get(owner)!.push(...newItems)
// Round-robin interleave
const groupArrays = ownerOrder.map(o => groups.get(o)!)
const maxLen = Math.max(...groupArrays.map(g => g.length))
const interleaved: QueueItem[] = []
for (let i = 0; i < maxLen; i++) {
for (const group of groupArrays) {
if (i < group.length) interleaved.push(group[i])
}
}
const newQueue = [...played, ...interleaved]
// If nothing was playing yet, start from 0
const newCurIdx = curIdx < 0 ? 0 : curIdx
set((state) => ({
queue: newQueue,
curIdx: newCurIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
loadPlaylist: (tracks: string[]) => {
const color = COLORS[0]
const queue: QueueItem[] = tracks.map((title, ti) => ({
title,
owner: 'Соло',
color,
_pi: 0,
_ti: ti,
img: '',
}))
set((state) => ({
people: [{ name: 'Соло', color, tracks: tracks.map((t) => ({ title: t })) }],
queue,
curIdx: 0,
loadKey: state.loadKey + 1,
}))
},
removeFromQueue: (idx) => {
set((state) => {
const queue = state.queue.filter((_, i) => i !== idx)
let curIdx = state.curIdx
if (idx < curIdx) curIdx--
else if (idx === curIdx) curIdx = Math.min(curIdx, queue.length - 1)
return { queue, curIdx }
})
},
addTrackToQueue: (title) => {
const { curIdx } = get()
const color = COLORS[0]
const newItem: QueueItem = { title, owner: 'Remote', color, _pi: 0, _ti: 0, img: '' }
set((state) => ({
queue: [...state.queue, newItem],
curIdx: curIdx < 0 ? 0 : state.curIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
setCurIdx: (idx) => {
set((state) => ({ curIdx: idx, loadKey: state.loadKey + 1 }))
},
setQueue: (queue) => set({ queue }),
updateQueueItemImg: (idx, img) => {
set((state) => {
const queue = [...state.queue]
if (queue[idx]) queue[idx] = { ...queue[idx], img }
return { queue }
})
},
reorderQueue: (fromIdx, toIdx) => {
set((state) => {
const queue = [...state.queue]
const [moved] = queue.splice(fromIdx, 1)
queue.splice(toIdx, 0, moved)
let { curIdx } = state
if (curIdx === fromIdx) curIdx = toIdx
else if (fromIdx < curIdx && toIdx >= curIdx) curIdx--
else if (fromIdx > curIdx && toIdx <= curIdx) curIdx++
return { queue, curIdx }
})
},
setCurrentResults: (results) => set({ currentResults: results }),
setSearchStatus: (searchStatus) => set({ searchStatus }),
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
addToHistory: (entry) => {
set((state) => ({ history: [entry, ...state.history] }))
},
clearHistory: () => set({ history: [] }),
}))

View File

@@ -9,6 +9,7 @@ export interface AccentPreset {
} }
export const ACCENT_PRESETS: AccentPreset[] = [ export const ACCENT_PRESETS: AccentPreset[] = [
{ name: 'Лаванда', accent: '#de9cfe', rgb: '222,156,254' },
{ name: 'Лайм', accent: '#c8ff00', rgb: '200,255,0' }, { name: 'Лайм', accent: '#c8ff00', rgb: '200,255,0' },
{ name: 'Синий', accent: '#00D4FF', rgb: '0,212,255' }, { name: 'Синий', accent: '#00D4FF', rgb: '0,212,255' },
{ name: 'Розовый', accent: '#FF2D78', rgb: '255,45,120' }, { name: 'Розовый', accent: '#FF2D78', rgb: '255,45,120' },

View File

@@ -0,0 +1,27 @@
'use client'
import { create } from 'zustand'
interface Toast {
id: number
message: string
type: 'error' | 'success' | 'info'
}
interface ToastStore {
toasts: Toast[]
show: (message: string, type?: Toast['type']) => void
dismiss: (id: number) => void
}
let nextId = 0
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
show: (message, type = 'info') => {
const id = ++nextId
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }))
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 3500)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { SearchResult } from '@/types' import type { SearchResult } from '@/types'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080' const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const STORAGE_KEY = 'pm_versions' const STORAGE_KEY = 'pm_versions'
@@ -35,16 +36,12 @@ function saveLocal(v: Record<string, SavedVersion>) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) } catch {} try { localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) } catch {}
} }
function getToken(): string | null {
return useAuthStore.getState().token
}
async function apiFetch(path: string, opts?: RequestInit): Promise<Response | null> { async function apiFetch(path: string, opts?: RequestInit): Promise<Response | null> {
const token = getToken() if (!useAuthStore.getState().user) return null
if (!token) return null
return fetch(`${API_URL}${path}`, { return fetch(`${API_URL}${path}`, {
credentials: 'include',
...opts, ...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...opts?.headers }, headers: { 'Content-Type': 'application/json', ...opts?.headers },
}).catch(() => null) }).catch(() => null)
} }

View File

@@ -1,6 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -12,9 +16,25 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
"paths": { "@/*": ["./src/*"] } {
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

View File

@@ -29,8 +29,9 @@ services:
GIN_MODE: release GIN_MODE: release
PORT: 8080 PORT: 8080
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
ports: ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost}
- "8081:8080" SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID:-}
SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET:-}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -41,14 +42,21 @@ services:
context: ./apps/web context: ./apps/web
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} NEXT_PUBLIC_API_URL: ""
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
ports:
- "3001:3000"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
nginx:
image: nginx:alpine
volumes:
- ./apps/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "${APP_PORT:-80}:80"
depends_on:
- web
- backend
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data: