diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go
index e09c439..d29478e 100644
--- a/apps/backend/internal/config/config.go
+++ b/apps/backend/internal/config/config.go
@@ -7,25 +7,29 @@ import (
)
type Config struct {
- Port string
- DBHost string
- DBPort string
- DBUser string
- DBPass string
- DBName string
- JWTSecret string
+ Port string
+ DBHost string
+ DBPort string
+ DBUser string
+ DBPass string
+ DBName string
+ JWTSecret string
+ AllowedOrigins string
+ CookieSecure bool
}
func Load() *Config {
_ = godotenv.Load()
return &Config{
- Port: getEnv("PORT", "8080"),
- DBHost: getEnv("DB_HOST", "localhost"),
- DBPort: getEnv("DB_PORT", "5432"),
- DBUser: getEnv("DB_USER", "partymix"),
- DBPass: getEnv("DB_PASSWORD", "partymix"),
- DBName: getEnv("DB_NAME", "partymix"),
- JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
+ Port: getEnv("PORT", "8080"),
+ DBHost: getEnv("DB_HOST", "localhost"),
+ DBPort: getEnv("DB_PORT", "5432"),
+ DBUser: getEnv("DB_USER", "partymix"),
+ DBPass: getEnv("DB_PASSWORD", "partymix"),
+ DBName: getEnv("DB_NAME", "partymix"),
+ JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
+ AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3001,http://localhost:3000"),
+ CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
}
}
diff --git a/apps/backend/internal/handlers/auth.go b/apps/backend/internal/handlers/auth.go
index 400b97d..a3dd1a5 100644
--- a/apps/backend/internal/handlers/auth.go
+++ b/apps/backend/internal/handlers/auth.go
@@ -12,6 +12,9 @@ import (
"gorm.io/gorm"
)
+const cookieName = "pm_token"
+const cookieMaxAge = 60 * 60 * 24 * 30 // 30 days
+
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
@@ -58,7 +61,7 @@ type loginReq struct {
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) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -83,10 +86,15 @@ func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
return
}
- c.JSON(http.StatusOK, gin.H{
- "token": token,
- "user": user,
- })
+ c.SetCookie(cookieName, token, cookieMaxAge, "/", "", cookieSecure, true)
+ c.JSON(http.StatusOK, gin.H{"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})
}
}
diff --git a/apps/backend/internal/handlers/middleware.go b/apps/backend/internal/handlers/middleware.go
index f6637b6..b9a1338 100644
--- a/apps/backend/internal/handlers/middleware.go
+++ b/apps/backend/internal/handlers/middleware.go
@@ -43,8 +43,13 @@ func parseToken(tokenStr, secret string) (*jwtClaims, error) {
func AuthRequired(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
- header := c.GetHeader("Authorization")
- tokenStr := strings.TrimPrefix(header, "Bearer ")
+ // Cookie-first, Bearer header as fallback for backwards compat
+ tokenStr, err := c.Cookie(cookieName)
+ if err != nil || tokenStr == "" {
+ header := c.GetHeader("Authorization")
+ tokenStr = strings.TrimPrefix(header, "Bearer ")
+ }
+
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
diff --git a/apps/backend/internal/handlers/overlay.go b/apps/backend/internal/handlers/overlay.go
new file mode 100644
index 0000000..573c435
--- /dev/null
+++ b/apps/backend/internal/handlers/overlay.go
@@ -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
+ }
+ }
+}
diff --git a/apps/backend/internal/handlers/proxy.go b/apps/backend/internal/handlers/proxy.go
index 912e9ed..4894fe4 100644
--- a/apps/backend/internal/handlers/proxy.go
+++ b/apps/backend/internal/handlers/proxy.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
+ "os"
"regexp"
"strings"
"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) {
if redirectCount > 5 {
c.Status(http.StatusInternalServerError)
diff --git a/apps/backend/internal/models/models.go b/apps/backend/internal/models/models.go
index 3ecf6fa..1da8642 100644
--- a/apps/backend/internal/models/models.go
+++ b/apps/backend/internal/models/models.go
@@ -52,11 +52,11 @@ type User struct {
type Playlist struct {
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"`
- 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:"-"`
- 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"`
}
diff --git a/apps/backend/internal/router/router.go b/apps/backend/internal/router/router.go
index c1e172a..645b86c 100644
--- a/apps/backend/internal/router/router.go
+++ b/apps/backend/internal/router/router.go
@@ -2,22 +2,29 @@ package router
import (
"net/http"
+ "strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
+ "github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/handlers"
"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()
+ origins := strings.Split(cfg.AllowedOrigins, ",")
+ for i, o := range origins {
+ origins[i] = strings.TrimSpace(o)
+ }
+
r.Use(cors.New(cors.Config{
- AllowAllOrigins: true,
+ AllowOrigins: origins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
- AllowCredentials: false,
+ AllowCredentials: true,
}))
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("/mp3", handlers.MP3ProxyHandler)
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
+ proxy.GET("/spotify-playlist", handlers.SpotifyPlaylistHandler)
}
auth := r.Group("/api/auth")
{
auth.POST("/register", handlers.Register(db))
- auth.POST("/login", handlers.Login(db, jwtSecret))
- auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db))
+ auth.POST("/login", handlers.Login(db, cfg.JWTSecret, cfg.CookieSecure))
+ 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))
- versions := r.Group("/api/versions", handlers.AuthRequired(jwtSecret))
+ versions := r.Group("/api/versions", handlers.AuthRequired(cfg.JWTSecret))
{
versions.GET("", handlers.GetVersions(db))
versions.POST("", handlers.SaveVersion(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.POST("", handlers.CreatePlaylist(db))
@@ -59,6 +68,13 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
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.POST("", handlers.CreateRemoteRoom)
diff --git a/apps/backend/main.go b/apps/backend/main.go
index 7c69d86..5866a78 100644
--- a/apps/backend/main.go
+++ b/apps/backend/main.go
@@ -11,7 +11,7 @@ import (
func main() {
cfg := config.Load()
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)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err)
diff --git a/apps/nginx/nginx.conf b/apps/nginx/nginx.conf
new file mode 100644
index 0000000..4b94444
--- /dev/null
+++ b/apps/nginx/nginx.conf
@@ -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;
+ }
+ }
+}
diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore
new file mode 100644
index 0000000..276606b
--- /dev/null
+++ b/apps/web/.dockerignore
@@ -0,0 +1,5 @@
+node_modules
+.next
+.git
+*.log
+.env*.local
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 0d92e36..3fbeaff 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -9,6 +9,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/src/app/app/page.tsx b/apps/web/src/app/(main)/app/page.tsx
similarity index 100%
rename from apps/web/src/app/app/page.tsx
rename to apps/web/src/app/(main)/app/page.tsx
diff --git a/apps/web/src/app/(main)/community/page.tsx b/apps/web/src/app/(main)/community/page.tsx
new file mode 100644
index 0000000..dcccf4c
--- /dev/null
+++ b/apps/web/src/app/(main)/community/page.tsx
@@ -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 (
+
+
+ {tracks.map((track, i) => (
+
+ {i + 1}
+ {track.title}
+
+ ))}
+
+
+ )
+}
+
+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 (
+
+
+
+ {pl.username[0].toUpperCase()}
+
+
+
+
{pl.name}
+
+ {pl.username}
+ ·
+ {trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}
+ {tags.map(tag => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ {trackCount > 0 && (
+
+ )}
+
+ {canFork && trackCount > 0 && (
+
+ )}
+
+
+
+
+
+ {expanded && pl.tracks && pl.tracks.length > 0 &&
}
+
+ )
+}
+
+function SortButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function TagToggleButton({
+ count, open, onClick,
+}: {
+ count: number
+ open: boolean
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+const TAG_VISIBLE_DEFAULT = 12
+
+function TagPanel({
+ allTags,
+ activeTags,
+ tagCounts,
+ onToggle,
+ onClear,
+}: {
+ allTags: string[]
+ activeTags: Set
+ tagCounts: Record
+ 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 (
+
+
+
+
+
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 && (
+
+ )}
+
+ {count > 0 && (
+
+ )}
+
+
+ {matched.length === 0 ? (
+
Не найдено
+ ) : (
+ <>
+
+ {visible.map(tag => {
+ const active = activeTags.has(tag)
+ const color = tagColor(tag)
+ return (
+
+ )
+ })}
+
+
+ {!q && hiddenCount > 0 && (
+
+ )}
+ >
+ )}
+
+ )
+}
+
+export default function CommunityPage() {
+ const [playlists, setPlaylists] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [launched, setLaunched] = useState(null)
+ const [search, setSearch] = useState('')
+ const [activeTags, setActiveTags] = useState>(new Set())
+ const [tagsOpen, setTagsOpen] = useState(false)
+ const [sort, setSort] = useState('newest')
+ const [forkStates, setForkStates] = useState>({})
+ 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>((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 (
+
+
+
+
+
+
Сообщество
+ {!loading && (
+ {playlists.length} плейлистов
+ )}
+
+
Публичные плейлисты пользователей
+
+
+ {!loading && playlists.length > 0 && (
+ <>
+ {/* Stats bar */}
+
+
+
+
{filteredTracks}
+
треков
+
+
+
+
+
{new Set(filtered.map(pl => pl.username)).size}
+
{new Set(filtered.map(pl => pl.username)).size === 1 ? 'автор' : new Set(filtered.map(pl => pl.username)).size < 5 ? 'автора' : 'авторов'}
+
+
+
+
+
{filtered.length}
+
{filtered.length === 1 ? 'плейлист' : filtered.length < 5 ? 'плейлиста' : 'плейлистов'}
+
+
+
+ {/* Search + sort + tags */}
+
+
+
+
+
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 && (
+
+ )}
+
+
+ {allTags.length > 0 && (
+
setTagsOpen(v => !v)}
+ />
+ )}
+
+
+ {tagsOpen && allTags.length > 0 && (
+
setActiveTags(new Set())}
+ />
+ )}
+
+ {/* Sort */}
+
+ setSort('newest')}>Новые
+ setSort('tracks')}>По трекам
+ setSort('alpha')}>А–Я
+
+
+
+ {/* Active tag chips */}
+ {activeTags.size > 0 && (
+
+ {Array.from(activeTags).map(tag => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ {loading ? (
+
+ ) : !filtered.length ? (
+
+
🎵
+
+ {playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
+
+
+ {playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
+
+
+ ) : (
+ <>
+
+
+ {filtered.length !== playlists.length
+ ? `${filtered.length} из ${playlists.length} · ${filteredTracks} треков`
+ : `${filtered.length} плейлистов · ${filteredTracks} треков`
+ }
+
+
+
+
+
+ {filtered.map(pl => (
+
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}
+ />
+ ))}
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/web/src/app/(main)/layout.tsx b/apps/web/src/app/(main)/layout.tsx
new file mode 100644
index 0000000..c7e9a5f
--- /dev/null
+++ b/apps/web/src/app/(main)/layout.tsx
@@ -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 (
+
+ )
+}
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/(main)/login/page.tsx
similarity index 97%
rename from apps/web/src/app/login/page.tsx
rename to apps/web/src/app/(main)/login/page.tsx
index ebf6f51..710a14e 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/(main)/login/page.tsx
@@ -31,9 +31,9 @@ export default function LoginPage() {
setError('')
setLoading(true)
try {
- const { token, user } = await login(email, password)
- setAuth(token, user)
- router.push('/')
+ const user = await login(email, password)
+ setAuth(user)
+ router.push('/app')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {
diff --git a/apps/web/src/app/(main)/page.tsx b/apps/web/src/app/(main)/page.tsx
new file mode 100644
index 0000000..ffef904
--- /dev/null
+++ b/apps/web/src/app/(main)/page.tsx
@@ -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: (
+
+ ),
+ },
+ {
+ title: 'Умный шаффл',
+ desc: 'Честный режим или случайный — музыка для всех, без диктатуры',
+ icon: (
+
+ ),
+ },
+ {
+ title: 'Плейлисты',
+ desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
+ icon: (
+
+ ),
+ },
+ {
+ title: 'Пульт с телефона',
+ desc: 'Управляй плеером с любого телефона по QR-ссылке — без установки',
+ icon: (
+
+ ),
+ },
+]
+
+export default function LandingPage() {
+ return (
+
+
+ {/* Nav */}
+
+
+ {/* Hero */}
+
+ {/* Glow */}
+
+
+ {/* EQ */}
+
+ {EQ_BARS.map(({ h, d }, i) => (
+
+ ))}
+
+
+ {/* Title */}
+
+ PartyMix
+
+
+ {/* Tagline */}
+
+ Совместные плейлисты для вечеринок.
+ Каждый гость — часть музыки.
+
+
+ {/* CTA */}
+
+
+ Начать вечеринку →
+
+
+ Войти
+
+
+
+
+
+ {/* Steps */}
+
+ Как начать
+
+ {STEPS.map(({ n, title, desc }) => (
+
+ ))}
+
+
+
+ {/* Divider */}
+
+
+ {/* Features */}
+
+ Возможности
+
+ {FEATURES.map(({ icon, title, desc }) => (
+
+
+ {icon}
+
+
{title}
+
{desc}
+
+ ))}
+
+
+
+ {/* Footer */}
+
+
+
+ )
+}
diff --git a/apps/web/src/app/playlists/page.tsx b/apps/web/src/app/(main)/playlists/page.tsx
similarity index 74%
rename from apps/web/src/app/playlists/page.tsx
rename to apps/web/src/app/(main)/playlists/page.tsx
index df52507..cce8cb2 100644
--- a/apps/web/src/app/playlists/page.tsx
+++ b/apps/web/src/app/(main)/playlists/page.tsx
@@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/store/favoritesStore'
import { usePartyStore } from '@/store/partyStore'
import { useVersionStore } from '@/store/versionStore'
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 Header from '@/components/Header'
@@ -161,16 +161,21 @@ function PlaylistCard({
pl,
onEdit,
onDelete,
+ onAddTrack,
}: {
pl: Playlist
onEdit: () => void
onDelete: () => void
+ onAddTrack: (id: string, title: string) => Promise
}) {
const { loadPlaylist } = usePartyStore()
const [expanded, setExpanded] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [versionsFor, setVersionsFor] = useState(null)
const [launched, setLaunched] = useState(false)
+ const [addingTrack, setAddingTrack] = useState(false)
+ const [quickAdd, setQuickAdd] = useState('')
+ const [quickAdding, setQuickAdding] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
@@ -201,6 +206,16 @@ function PlaylistCard({
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 (
@@ -250,6 +265,14 @@ function PlaylistCard({
)}
+
+ {addingTrack && (
+
+ 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"
+ />
+
+
+ )}
+
{expanded && pl.tracks && pl.tracks.length > 0 && (
{pl.tracks.map((track, i) => (
@@ -491,7 +535,7 @@ function FavoritesCard() {
)
}
-function YandexImportForm({ onImport, onClose }: {
+function YandexImportForm({ onImport }: {
onImport: (name: string, tracks: string[]) => Promise
onClose: () => void
}) {
@@ -533,18 +577,7 @@ function YandexImportForm({ onImport, onClose }: {
}
return (
-
-
-
- Импорт из Яндекс.Музыки
-
-
-
-
+
Promise
+ 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 (
+
+
+
{ 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"
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {preview && (
+ <>
+
+
+ Найдено треков:
+ {preview.tracks.length}
+
+
+ {preview.tracks.map((t, i) => (
+
+ {i + 1}
+ {t}
+
+ ))}
+
+
+
+
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="Название плейлиста"
+ />
+
+
+ >
+ )}
+
+ )
+}
+
export default function PlaylistsPage() {
const router = useRouter()
- const { token, user } = useAuthStore()
+ const { user } = useAuthStore()
const { hydrate: hydrateFavorites } = useFavoritesStore()
const [playlists, setPlaylists] = useState([])
const [loading, setLoading] = useState(true)
+ const [plSearch, setPlSearch] = useState('')
+ const [plSort, setPlSort] = useState<'date' | 'name' | 'tracks'>('date')
const [newName, setNewName] = useState('')
const [newTracks, setNewTracks] = useState('')
@@ -626,30 +768,31 @@ export default function PlaylistsPage() {
const [editingId, setEditingId] = useState(null)
const [showImport, setShowImport] = useState(false)
+ const [importSource, setImportSource] = useState<'yandex' | 'spotify'>('yandex')
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
useEffect(() => {
- if (!token) { router.push('/login'); return }
- getPlaylists(token)
+ if (!user) { router.push('/login'); return }
+ getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false))
- }, [token, router])
+ }, [user, router])
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
- if (!token || !newName.trim()) return
+ if (!user || !newName.trim()) return
const tracks = parseTracks(newTracks)
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
setCreateError('')
setSaving(true)
try {
- const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags)
+ const pl = await createPlaylist(newName.trim(), tracks, newIsPublic, newTags)
setPlaylists(prev => [pl, ...prev])
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
setShowForm(false)
@@ -661,23 +804,39 @@ export default function PlaylistsPage() {
}
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
- if (!token) return
+ if (!user) return
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))
setEditingId(null)
} catch {}
}
const handleDelete = async (id: string) => {
- if (!token) return
+ if (!user) return
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[]) => {
- if (!token) return
- const pl = await createPlaylist(token, name, tracks, false, [])
+ if (!user) return
+ const pl = await createPlaylist(name, tracks, false, [])
setPlaylists(prev => [pl, ...prev])
setShowImport(false)
}
@@ -740,10 +899,33 @@ export default function PlaylistsPage() {
{showImport && (
- setShowImport(false)}
- />
+
+
+
+ {(['yandex', 'spotify'] as const).map(src => (
+
+ ))}
+
+
+
+ {importSource === 'yandex'
+ ?
setShowImport(false)} />
+ : setShowImport(false)} />
+ }
+
)}
{showForm && (
@@ -801,24 +983,56 @@ export default function PlaylistsPage() {
Нажмите «Создать» чтобы добавить первый
) : (
-
- {playlists.map(pl => (
-
-
setEditingId(editingId === pl.id ? null : pl.id)}
- onDelete={() => handleDelete(pl.id)}
+ <>
+ {/* Search + sort */}
+
+
+
+
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 && (
-
handleUpdate(pl.id, name, tracks, isPublic, tags)}
- onCancel={() => setEditingId(null)}
- />
- )}
- ))}
-
+
+
+
+
+ {filteredPlaylists.map(pl => (
+
+
setEditingId(editingId === pl.id ? null : pl.id)}
+ onDelete={() => handleDelete(pl.id)}
+ onAddTrack={handleAddTrack}
+ />
+ {editingId === pl.id && (
+ handleUpdate(pl.id, name, tracks, isPublic, tags)}
+ onCancel={() => setEditingId(null)}
+ />
+ )}
+
+ ))}
+ {filteredPlaylists.length === 0 && plSearch && (
+
Ничего не найдено
+ )}
+
+ >
)}
)
diff --git a/apps/web/src/app/register/page.tsx b/apps/web/src/app/(main)/register/page.tsx
similarity index 100%
rename from apps/web/src/app/register/page.tsx
rename to apps/web/src/app/(main)/register/page.tsx
diff --git a/apps/web/src/app/(main)/remote/[id]/page.tsx b/apps/web/src/app/(main)/remote/[id]/page.tsx
new file mode 100644
index 0000000..43a90c7
--- /dev/null
+++ b/apps/web/src/app/(main)/remote/[id]/page.tsx
@@ -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
(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>({})
+ 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(null)
+ const volumeDebounce = useRef | 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 (
+
+
+
+
Сессия не найдена
+
Ссылка устарела или сессия завершена
+
+
+ )
+
+ if (!state) return (
+
+ )
+
+ 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 (
+
+
+ {/* Header */}
+
+
+
+ Party Mix · Пульт
+
+
+ {(['player', 'queue'] as const).map(t => (
+
+ ))}
+
+
+
+ {tab === 'player' && (
+
+
+ {/* Cover */}
+
+ {state.cover ? (
+

+ ) : (
+
+ )}
+
+
+ {/* Track info */}
+
+
+
+
+ {state.title || '—'}
+
+ {state.artist && (
+
{state.artist}
+ )}
+
+
+ {currentOwner && (
+
+ {currentOwner.owner}
+
+ )}
+ {state.queue_len > 0 && (
+ {state.cur_idx + 1} / {state.queue_len}
+ )}
+
+
+
+
+ {/* Progress bar */}
+
+
{ e.preventDefault(); handleSeekStart(e.clientX) }}
+ onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }}
+ >
+
+
+
+ {formatTime(clampedProgress)}
+ {formatTime(state.duration)}
+
+
+
+ {/* Controls */}
+
+
+
+
+
+
+
+
+ {/* Volume */}
+
+
+
{
+ 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)' }}
+ />
+
+
+
+ {/* Versions */}
+ {versions.length > 1 && (
+
+
+ {versionsOpen && (
+
+ {versions.map((v, i) => {
+ const active = i === state.active_version
+ const saved = isSavedLocally(v)
+ return (
+
+
{i + 1}
+ {v.img ? (
+

((e.target as HTMLImageElement).style.display = 'none')} />
+ ) : (
+
+ )}
+
{ cmd(id, 'version', i); setVersionsOpen(false) }}>
+
{v.title}
+
{v.artist}
+
+
{v.duration}
+
+
+
+ )
+ })}
+
+ )}
+
+ )}
+
+ )}
+
+ {tab === 'queue' && (
+
+
+ {/* Add track */}
+
+ 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"
+ />
+
+
+
+ {/* Queue list */}
+ {queue.length === 0 ? (
+
+
+
Очередь пуста
+
+ ) : (
+
+ {queue.map((item, i) => {
+ const active = i === state.cur_idx
+ return (
+
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 */}
+
+ {active ? (
+
+ ) : (
+
{i + 1}
+ )}
+
+
+ {/* Cover */}
+ {item.img ? (
+

((e.target as HTMLImageElement).style.display = 'none')} />
+ ) : (
+
+ )}
+
+ {/* Title + owner */}
+
+ {item.title}
+
+ {item.owner}
+
+
+
+ {/* Remove */}
+
+
+ )
+ })}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/(main)/search/page.tsx
similarity index 78%
rename from apps/web/src/app/search/page.tsx
rename to apps/web/src/app/(main)/search/page.tsx
index 9a1bcf2..322f198 100644
--- a/apps/web/src/app/search/page.tsx
+++ b/apps/web/src/app/(main)/search/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useRef, useState } from 'react'
+import { useRef, useState, useEffect, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
@@ -9,13 +9,31 @@ import AddToPlaylist from '@/components/AddToPlaylist'
import Header from '@/components/Header'
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 }) {
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion } = useVersionStore()
const [playlistOpen, setPlaylistOpen] = useState(false)
const addBtnRef = useRef(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 hasImg = result.img && !result.img.includes('no-cover')
@@ -94,7 +112,7 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
{/* Favorite */}
+ {/* Search history */}
+ {!loading && results === null && searchHistory.length > 0 && (
+
+
+ Недавние
+
+
+
+ {searchHistory.map((q) => (
+
+ ))}
+
+
+ )}
+
{/* Loading */}
{loading && (
diff --git a/apps/web/src/app/(main)/settings/page.tsx b/apps/web/src/app/(main)/settings/page.tsx
new file mode 100644
index 0000000..66efde7
--- /dev/null
+++ b/apps/web/src/app/(main)/settings/page.tsx
@@ -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 (
+
+ )
+}
+
+function WavesPreview() {
+ return (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function AuroraPreview() {
+ return (
+
+ )
+}
+
+function PulsePreview() {
+ return (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function RaysPreview() {
+ const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
+ return (
+
+ )
+}
+
+function NonePreview() {
+ return (
+
+ )
+}
+
+const BG_PREVIEWS: Record
= {
+ orbs: ,
+ waves: ,
+ particles: ,
+ aurora: ,
+ pulse: ,
+ stars: ,
+ rain: ,
+ rays: ,
+ none: ,
+}
+
+// ── 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> = {
+ 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 (
+
+ )
+}
+
+// ── Overlay style previews ────────────────────────────────────────────────────
+
+function OverlayStylePreview({ style }: { style: OverlayStyle }) {
+ const W = 160, H = 90
+ if (style === 'classic') return (
+
+ )
+ if (style === 'aero') return (
+
+ )
+ if (style === 'retro') return (
+
+ )
+ if (style === 'neon') return (
+
+ )
+ if (style === 'clean') return (
+
+ )
+ if (style === 'y2k') return (
+
+ )
+ if (style === 'lofi') return (
+
+ )
+ if (style === 'glam') return (
+
+ )
+ // matrix
+ return (
+
+ )
+}
+
+// ── 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('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(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 (
+
+
+
+
+
Настройки
+
+ {/* Tabs */}
+
+ {([['appearance', 'Внешний вид'], ['overlay', 'Оверлей']] as [SettingsTab, string][]).map(([id, label]) => (
+
+ ))}
+
+
+ {/* ── Appearance tab ─────────────────────────────────────────────── */}
+ {tab === 'appearance' && (
+
+
+ Внешний вид
+
+
+ {/* ── Accent color (collapsible) ─────────────────────────────── */}
+
+
+ {showAccent && (
+
+
+ {ACCENT_PRESETS.map((preset, i) => (
+
+ ))}
+
+
+
+ {accentIdx === -1 && (
+
+
+
+ )}
+
+ )}
+
+ {/* ── Live background (collapsible) ─────────────────────────── */}
+
+
+ {showBg && (
+
+
+ {BG_PRESETS.map((preset) => {
+ const active = bgMode === preset.id
+ return (
+
+ )
+ })}
+
+
+ {/* Per-effect config panel */}
+ {activeFxMode && fxSliders && (
+
+
+
+ Настройки эффекта
+
+
+
+
+ {fxSliders.map(({ key, label, min, max, step, fmt }) => {
+ const val = (fxConfigs[activeFxMode] as unknown as Record
)[key] ?? (DEFAULT_FX[activeFxMode] as unknown as Record)[key]
+ return (
+
+
+ {label}
+
+ {fmt(val)}
+
+
+
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)' }}
+ />
+
+ )
+ })}
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* ── Overlay tab ────────────────────────────────────────────────── */}
+ {tab === 'overlay' && (
+
+
+ {/* ── Header: enable toggle + URL ──────────────────────────────── */}
+
+
+
+
Оверлей для стрима
+
{overlayUrl}
+
+
+
+
+ {/* ── Live preview ─────────────────────────────────────────────── */}
+
+
+ {showOvPreview && }
+
+
+ {/* ── Style picker ─────────────────────────────────────────────── */}
+
+
+ {showOvStyle &&
+ {(Object.entries(OVERLAY_STYLES) as [OverlayStyle, typeof OVERLAY_STYLES[OverlayStyle]][]).map(([id, { name, desc }]) => {
+ const active = overlayStyle === id
+ return (
+
+ )
+ })}
+
}
+
+
+ {/* ── Palette picker ───────────────────────────────────────────── */}
+
+
+ {showOvPalette && <>
+
+ {getPalettes(overlayStyle).map((pal) => {
+ const active = overlayPalette === pal.id
+ return (
+
+ )
+ })}
+
+ {/* 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 (
+
+ )
+ })()}
+
+
+ {/* 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 (
+
+
+ {fields.map((f) => {
+ const val = cp[f.key] ?? f.default
+ const isActive = currentField === f.key
+ return (
+
+ )
+ })}
+
+ {currentField && (
+
setCustomPaletteField(overlayStyle, currentField, hex)}
+ />
+ )}
+
+ )
+ })()}
+ >}
+
+
+ {/* ── Colors: accent + text ─────────────────────────────────────── */}
+
+
+ {showOvColors && <>
+
+ {/* Accent color */}
+
+ {/* Text color */}
+
+
+ {activeColorPicker === 'accent' && (
+
+
+
+ )}
+ {activeColorPicker === 'text' && (
+
+
+ {overlayTextColor && (
+
+ )}
+
+ )}
+ >}
+
+
+ {/* ── Font: horizontal chips ───────────────────────────────────── */}
+
+
+ {showOvFont &&
+ {OVERLAY_FONTS.map(({ id, name, css }) => {
+ const active = overlayFont === id
+ return (
+
+ )
+ })}
+
}
+
+
+ {/* ── Layout: position + cover/eq ──────────────────────────────── */}
+
+
+ {showOvLayout &&
+ {/* Position grid */}
+
+
Позиция
+
+ {OVERLAY_POSITIONS.map(({ id, arrow, label }) => {
+ const active = overlayPosition === id
+ return (
+
+ )
+ })}
+
+
+
+ {/* Toggles */}
+
+ {([
+ { label: 'Обложка', val: overlayShowCover, set: setOverlayShowCover },
+ { label: 'EQ-анимация', val: overlayShowEq, set: setOverlayShowEq },
+ ] as const).map(({ label, val, set }) => (
+
+ {label}
+
+
+ ))}
+
+
}
+
+ {/* Sliders: margin / scale / opacity */}
+ {showOvLayout && (
+
+ {([
+ { 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 }) => (
+
+
+ {label}
+ {fmt(value)}
+
+
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)' }}
+ />
+
+ ))}
+
+ )}
+
+
+ {/* ── Note ─────────────────────────────────────────────────────── */}
+
+
+ В OBS: источник «Браузер» → вставь URL. CSS: body {'{ background: transparent !important; }'}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/app/community/page.tsx b/apps/web/src/app/community/page.tsx
deleted file mode 100644
index 89f55f9..0000000
--- a/apps/web/src/app/community/page.tsx
+++ /dev/null
@@ -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 (
-
-
- {tracks.map((track, i) => (
-
- {i + 1}
- {track.title}
-
- ))}
-
-
- )
-}
-
-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 (
-
-
-
- {pl.username[0].toUpperCase()}
-
-
-
-
{pl.name}
-
- {pl.username}
- ·
- {trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}
- {tags.map(tag => (
-
- {tag}
-
- ))}
-
-
-
-
- {trackCount > 0 && (
-
- )}
-
-
-
-
- {expanded && pl.tracks && pl.tracks.length > 0 && (
-
- )}
-
- )
-}
-
-export default function CommunityPage() {
- const [playlists, setPlaylists] = useState([])
- const [loading, setLoading] = useState(true)
- const [launched, setLaunched] = useState(null)
- const [search, setSearch] = useState('')
- const [activeTag, setActiveTag] = useState(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 (
-
-
-
-
-
-
Сообщество
- {!loading && (
- {playlists.length} плейлистов
- )}
-
-
Публичные плейлисты пользователей
-
-
- {!loading && playlists.length > 0 && (
-
-
-
-
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 && (
-
- )}
-
-
- {allTags.length > 0 && (
-
- {allTags.map(tag => (
-
- ))}
-
- )}
-
- )}
-
- {loading ? (
-
- ) : !filtered.length ? (
-
-
🎵
-
- {playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
-
-
- {playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
-
-
- ) : (
-
- {filtered.map(pl => (
-
handlePlay(pl)}
- isLaunched={launched === pl.id}
- />
- ))}
-
- )}
-
- )
-}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 6d48b10..a04ccfa 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -4,8 +4,8 @@
:root {
--border: rgba(255, 255, 255, 0.07);
- --accent: #c8ff00;
- --accent-rgb: 200,255,0;
+ --accent: #de9cfe;
+ --accent-rgb: 222,156,254;
}
*,
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index cdf1cf5..9248c42 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,9 +1,5 @@
import type { Metadata, Viewport } from 'next'
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'
const syne = Syne({
@@ -29,17 +25,16 @@ export const viewport: Viewport = {
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 }) {
return (
-
-
-
-
-
+
+
+
+
+ {children}
)
diff --git a/apps/web/src/app/overlay/[token]/page.tsx b/apps/web/src/app/overlay/[token]/page.tsx
new file mode 100644
index 0000000..1e3115e
--- /dev/null
+++ b/apps/web/src/app/overlay/[token]/page.tsx
@@ -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 = {
+ 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(null)
+ const [notFound, setNotFound] = useState(false)
+ const hideTimer = useRef | 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 | 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 (
+
+ overlay not found
+
+ )
+ 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
+}
+
+// Separate component so hooks (useLocalProgress) run after display is confirmed non-null
+function OverlayRenderer({
+ display, render, pal,
+}: {
+ display: OverlayState
+ render: (s: OverlayWidgetState, pal: ReturnType) => React.ReactNode
+ pal: ReturnType
+}) {
+ const progress = useLocalProgress(display.progress, display.duration, display.is_playing, display.updated_at)
+ const state = { ...display, progress }
+ return <>{render(state, pal)}>
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
deleted file mode 100644
index 33a2e45..0000000
--- a/apps/web/src/app/page.tsx
+++ /dev/null
@@ -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 (
-
-
- {/* ── Nav ── */}
-
-
- {/* ── Hero ── */}
-
-
- {/* Ambient glow behind EQ */}
-
-
- {/* EQ visualization */}
-
- {EQ_BARS.map(({ h, d }, i) => (
-
- ))}
-
-
- {/* Title */}
-
- PartyMix
-
-
- {/* Tagline */}
-
- Совместные плейлисты для вечеринок.
-
- Каждый гость — часть музыки.
-
-
- {/* CTA buttons */}
-
-
- Начать вечеринку →
-
-
- Войти
-
-
-
-
- {/* ── Divider ── */}
-
-
- {/* ── Features ── */}
-
-
- Возможности
-
-
- {FEATURES.map(({ icon, title, desc }) => (
-
-
{icon}
-
{title}
-
{desc}
-
- ))}
-
-
-
- {/* ── Footer ── */}
-
-
-
- )
-}
diff --git a/apps/web/src/app/remote/[id]/page.tsx b/apps/web/src/app/remote/[id]/page.tsx
deleted file mode 100644
index 87c5283..0000000
--- a/apps/web/src/app/remote/[id]/page.tsx
+++ /dev/null
@@ -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(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>({})
- 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 | 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 (
-
-
-
Сессия не найдена или истекла
-
- )
-
- if (!state) return (
-
- )
-
- 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 (
-
-
- {/* Header + tabs */}
-
-
Party Mix · Пульт
-
-
-
-
-
-
- {tab === 'player' && (
-
- {/* Cover */}
-
- {state.cover ? (
-

- ) : (
-
- )}
-
-
- {/* Track info */}
-
-
- {state.title || '—'}
-
- {state.artist && (
-
{state.artist}
- )}
- {state.queue_len > 0 && (
-
{state.cur_idx + 1} / {state.queue_len}
- )}
-
-
- {/* Progress bar */}
-
-
{
- 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)
- }}
- >
-
-
-
- {formatTime(clampedProgress)}
- {formatTime(state.duration)}
-
-
-
- {/* Controls */}
-
-
-
-
-
-
-
-
- {/* Volume */}
-
-
-
{
- 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)' }}
- />
-
-
-
- {/* Versions */}
- {versions.length > 1 && (
-
-
- {versionsOpen && (
-
- {versions.map((v, i) => {
- const active = i === state.active_version
- const saved = isSavedLocally(v)
- return (
-
-
{i + 1}
- {v.img ? (
-

((e.target as HTMLImageElement).style.display = 'none')} />
- ) : (
-
- )}
-
{ cmd(id, 'version', i); setVersionsOpen(false) }}>
-
{v.title}
-
{v.artist}
-
-
{v.duration}
-
-
-
- )
- })}
-
- )}
-
- )}
-
- )}
-
- {tab === 'queue' && (
-
- {/* Add track */}
-
- 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"
- />
-
-
-
- {/* Queue list */}
- {queue.length === 0 ? (
-
- Очередь пуста
-
- ) : (
-
- {queue.map((item, i) => {
- const active = i === state.cur_idx
- return (
-
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 ? (
-
- ) : (
-
{i + 1}
- )}
- {item.img ? (
-

((e.target as HTMLImageElement).style.display = 'none')} />
- ) : (
-
- )}
-
{item.title}
-
- {item.owner}
-
-
-
- )
- })}
-
- )}
-
- )}
-
-
- )
-}
diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx
deleted file mode 100644
index 0db8444..0000000
--- a/apps/web/src/app/settings/page.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
-
-function WavesPreview() {
- return (
-
- )
-}
-
-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 (
-
- )
-}
-
-function AuroraPreview() {
- return (
-
- )
-}
-
-function PulsePreview() {
- return (
-
- )
-}
-
-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 (
-
- )
-}
-
-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 (
-
- )
-}
-
-function RaysPreview() {
- const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
- return (
-
- )
-}
-
-function NonePreview() {
- return (
-
- )
-}
-
-const BG_PREVIEWS: Record = {
- orbs: ,
- waves: ,
- particles: ,
- aurora: ,
- pulse: ,
- stars: ,
- rain: ,
- rays: ,
- none: ,
-}
-
-// ── 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 (
-
-
-
-
-
Настройки
-
-
-
- Внешний вид
-
-
- {/* Accent color */}
- Акцентный цвет
-
- {ACCENT_PRESETS.map((preset, i) => (
-
- ))}
- {/* Custom color button */}
-
-
-
- {/* Color wheel — shown when custom is selected */}
- {accentIdx === -1 && (
-
-
-
- )}
- {accentIdx !== -1 && }
-
- {/* Live background */}
- Живой фон
-
- {BG_PRESETS.map((preset) => {
- const active = bgMode === preset.id
- return (
-
- )
- })}
-
-
- {/* Rays config — shown only when rays mode is active */}
- {bgMode === 'rays' && (
-
-
-
- Настройки лучей
-
-
-
-
- {([
- { 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 }) => (
-
-
- {label}
-
- {fmt(raysConfig[key])}
-
-
-
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)' }}
- />
-
- ))}
-
-
- )}
-
-
-
- )
-}
diff --git a/apps/web/src/components/AddToPlaylist.tsx b/apps/web/src/components/AddToPlaylist.tsx
index 6f61070..4693dc2 100644
--- a/apps/web/src/components/AddToPlaylist.tsx
+++ b/apps/web/src/components/AddToPlaylist.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { useEffect, useRef, useState } from 'react'
import { useFavoritesStore } from '@/store/favoritesStore'
@@ -14,16 +14,16 @@ interface Props {
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
const { isFavorite, toggleFavorite } = useFavoritesStore()
- const { token } = useAuthStore()
+ const { user } = useAuthStore()
const [playlists, setPlaylists] = useState([])
const [added, setAdded] = useState>({})
const ref = useRef(null)
const favorited = isFavorite(trackTitle)
useEffect(() => {
- if (!token) return
- getPlaylists(token).then(setPlaylists).catch(() => {})
- }, [token])
+ if (!user) return
+ getPlaylists().then(setPlaylists).catch(() => {})
+ }, [user])
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -37,9 +37,9 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
}, [onClose, anchorRef])
const handleAdd = async (playlist: Playlist) => {
- if (!token || added[playlist.id]) return
+ if (!user || added[playlist.id]) return
try {
- await addTrackToPlaylist(token, playlist.id, trackTitle)
+ await addTrackToPlaylist(playlist.id, trackTitle)
setAdded(prev => ({ ...prev, [playlist.id]: true }))
} catch {}
}
@@ -74,7 +74,7 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
)}
- {token ? (
+ {user ? (
playlists.length > 0 ? (
{playlists.map(pl => (
diff --git a/apps/web/src/components/AudioBackground.tsx b/apps/web/src/components/AudioBackground.tsx
index e9d4768..9afd595 100644
--- a/apps/web/src/components/AudioBackground.tsx
+++ b/apps/web/src/components/AudioBackground.tsx
@@ -10,12 +10,12 @@ type Star = { x: number; y: number; r: number; ba: number; ph: number; sp: numbe
type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
export default function AudioBackground() {
- const canvasRef = useRef
(null)
- const { bgMode, raysConfig } = useBgStore()
- const raysRef = useRef(raysConfig)
+ const canvasRef = useRef(null)
+ const { bgMode, fxConfigs } = useBgStore()
+ const fxRef = useRef(fxConfigs)
- // Keep rays config in sync without restarting the animation loop
- useEffect(() => { raysRef.current = raysConfig }, [raysConfig])
+ // Sync config changes without restarting the animation loop
+ useEffect(() => { fxRef.current = fxConfigs }, [fxConfigs])
useEffect(() => {
if (bgMode === 'none') return
@@ -28,7 +28,7 @@ export default function AudioBackground() {
let rafId: number
let smoothBass = 0
let smoothMid = 0
- let fastBass = 0 // fast tracker for onset/beat detection
+ let fastBass = 0
let dataBuf: Uint8Array | null = null
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight }
@@ -54,35 +54,45 @@ export default function AudioBackground() {
const ac = () => document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0'
- // ── ORBS — ambient, subtle ────────────────────────────────────────────────
+ const clear = (W: number, H: number, trail: number) => {
+ if (trail > 0.005) {
+ ctx.fillStyle = `rgba(10,10,15,${(1 - trail).toFixed(3)})`
+ ctx.fillRect(0, 0, W, H)
+ } else {
+ ctx.clearRect(0, 0, W, H)
+ ctx.fillStyle = '#0a0a0f'
+ ctx.fillRect(0, 0, W, H)
+ }
+ }
+
+ // ── ORBS ─────────────────────────────────────────────────────────────────
const drawOrbs = () => {
const W = canvas.width, H = canvas.height, a = ac()
- const t = Date.now() / 5000
+ const cfg = fxRef.current.orbs
+ const t = Date.now() / (5000 / cfg.speed)
const br = Math.sin(t) * 0.04 + Math.cos(t * 0.7) * 0.02
const diag = Math.hypot(W, H), base = diag * 0.62
+ const bri = cfg.brightness
- ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
+ clear(W, H, cfg.trail)
- // accent top-left
const r1 = base * (0.78 + smoothBass * 0.45 + br)
- const a1 = 0.07 + smoothBass * 0.08
+ const a1 = (0.07 + smoothBass * 0.08) * bri
const g1 = ctx.createRadialGradient(W * 0.12, H * 0.06, 0, W * 0.12, H * 0.06, r1)
g1.addColorStop(0, `rgba(${a},${a1})`); g1.addColorStop(0.42, `rgba(${a},${a1 * 0.25})`); g1.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
- // pink bottom-right
const r2 = base * (0.70 + smoothMid * 0.40 - br * 0.5)
- const a2 = 0.06 + smoothMid * 0.07
+ const a2 = (0.06 + smoothMid * 0.07) * bri
const g2 = ctx.createRadialGradient(W * 0.88, H * 0.94, 0, W * 0.88, H * 0.94, r2)
g2.addColorStop(0, `rgba(255,60,172,${a2})`); g2.addColorStop(0.42, `rgba(255,60,172,${a2 * 0.25})`); g2.addColorStop(1, 'rgba(255,60,172,0)')
ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
- // purple center — only shows with audio
const c = smoothBass * 0.5 + smoothMid * 0.5
if (c > 0.008) {
const r3 = base * (0.40 + c * 0.35)
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, r3)
- g3.addColorStop(0, `rgba(140,100,255,${0.035 + c * 0.06})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
+ g3.addColorStop(0, `rgba(140,100,255,${(0.035 + c * 0.06) * bri})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
}
}
@@ -90,14 +100,16 @@ export default function AudioBackground() {
// ── WAVES ─────────────────────────────────────────────────────────────────
const drawWaves = () => {
const W = canvas.width, H = canvas.height, a = ac()
- const t = Date.now() / 1000
- ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
+ const cfg = fxRef.current.waves
+ const t = (Date.now() / 1000) * cfg.speed
+ const amp = cfg.amplitude
+ clear(W, H, cfg.trail)
const layers = [
- { y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
- { y: 0.76, amp: 16 + smoothMid * 45, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
- { y: 0.81, amp: 24 + smoothBass * 75, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
- { y: 0.87, amp: 12 + smoothMid * 35, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
- { y: 0.93, amp: 30 + smoothBass * 90, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
+ { y: 0.70, amp: (20 + smoothBass * 60) * amp, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
+ { y: 0.76, amp: (16 + smoothMid * 45) * amp, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
+ { y: 0.81, amp: (24 + smoothBass * 75) * amp, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
+ { y: 0.87, amp: (12 + smoothMid * 35) * amp, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
+ { y: 0.93, amp: (30 + smoothBass * 90) * amp, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
]
for (const l of layers) {
const baseY = H * l.y
@@ -119,8 +131,9 @@ export default function AudioBackground() {
}))
const drawParticles = () => {
const W = canvas.width, H = canvas.height, a = ac()
- ctx.fillStyle = 'rgba(10,10,15,0.2)'; ctx.fillRect(0, 0, W, H)
- const spd = 1 + smoothBass * 3
+ const cfg = fxRef.current.particles
+ clear(W, H, cfg.trail)
+ const spd = (1 + smoothBass * 3) * cfg.speed
for (const p of PTS) {
p.x += p.vx * spd; p.y += p.vy * spd
if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1
@@ -133,7 +146,7 @@ export default function AudioBackground() {
ctx.beginPath(); ctx.arc(p.x * W, p.y * H, p.r * (1 + smoothBass * 1.2), 0, Math.PI * 2)
ctx.fillStyle = `rgba(${a},${p.a * (0.7 + smoothMid * 0.3)})`; ctx.fill()
}
- const maxD = Math.min(W, H) * 0.12; ctx.lineWidth = 0.5
+ const maxD = Math.min(W, H) * 0.12 * cfg.linkDist; ctx.lineWidth = 0.5
for (let i = 0; i < PTS.length; i++)
for (let j = i + 1; j < PTS.length; j++) {
const dx = (PTS[i].x - PTS[j].x) * W, dy = (PTS[i].y - PTS[j].y) * H
@@ -145,19 +158,21 @@ export default function AudioBackground() {
}
}
- // ── AURORA — subtle shimmer, not a flood ──────────────────────────────────
+ // ── AURORA ────────────────────────────────────────────────────────────────
const drawAurora = () => {
const W = canvas.width, H = canvas.height, a = ac()
- const t = Date.now() / 3500
- ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
+ const cfg = fxRef.current.aurora
+ const t = (Date.now() / 3500) * cfg.speed
+ const bri = cfg.brightness
+ clear(W, H, cfg.trail)
const bands = [
- { cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: 0.10 + smoothBass * 0.08, c: a },
- { cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: 0.08 + smoothMid * 0.07, c: '140,100,255' },
- { cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: 0.09 + smoothBass * 0.07, c: '255,60,172' },
- { cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: 0.07 + smoothMid * 0.06, c: a },
- { cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: 0.06 + smoothBass * 0.05, c: '140,100,255' },
- { cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: 0.05 + smoothMid * 0.05, c: '255,60,172' },
+ { cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: (0.10 + smoothBass * 0.08) * bri, c: a },
+ { cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: (0.08 + smoothMid * 0.07) * bri, c: '140,100,255' },
+ { cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: (0.09 + smoothBass * 0.07) * bri, c: '255,60,172' },
+ { cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: (0.07 + smoothMid * 0.06) * bri, c: a },
+ { cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: (0.06 + smoothBass * 0.05) * bri, c: '140,100,255' },
+ { cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: (0.05 + smoothMid * 0.05) * bri, c: '255,60,172' },
]
for (const band of bands) {
const cx = band.cx * W, cy = (band.cy + Math.sin(band.ph) * 0.07) * H
@@ -169,48 +184,68 @@ export default function AudioBackground() {
}
}
- // ── PULSE — beat-driven expanding rings ───────────────────────────────────
+ // ── PULSE — expanding ring waves ──────────────────────────────────────────
const RINGS: Ring[] = []
- let prevFast = 0
+ let lastRingMs = 0
+ let lastAmbMs = 0
const drawPulse = () => {
const W = canvas.width, H = canvas.height, a = ac()
+ const cfg = fxRef.current.pulse
const cx = W * 0.5, cy = H * 0.5
- const maxR = Math.hypot(W, H) * 0.8
+ const maxR = Math.hypot(W, H) * 0.72
- ctx.fillStyle = 'rgba(10,10,15,0.18)'; ctx.fillRect(0, 0, W, H)
+ clear(W, H, cfg.trail)
- // Beat onset: fastBass rises sharply above smoothBass
- const onset = fastBass > smoothBass + 0.10 && fastBass > 0.22 && fastBass > prevFast + 0.04
- if (onset) RINGS.push({ r: 10, alpha: 0.7 + fastBass * 0.3, speed: 3 + fastBass * 9 })
- prevFast = fastBass
+ const now = Date.now()
- // Slow ambient rings so mode looks alive without audio
- if (Math.random() < 0.008 && RINGS.length < 3) RINGS.push({ r: 5, alpha: 0.22, speed: 1.5 })
+ // Beat-triggered ring
+ const threshold = 0.45 - cfg.sensitivity * 0.3
+ if (fastBass > threshold && fastBass > smoothBass + 0.05 && now - lastRingMs > 200) {
+ RINGS.push({ r: 4, alpha: 1.0, speed: (5 + fastBass * 12) * cfg.ringSpeed })
+ lastRingMs = now
+ }
+
+ // Ambient ring on timer so it always looks alive even without audio
+ if (now - lastAmbMs > 1800) {
+ RINGS.push({ r: 4, alpha: 0.7, speed: 5 * cfg.ringSpeed })
+ lastAmbMs = now
+ }
for (let i = RINGS.length - 1; i >= 0; i--) {
const ring = RINGS[i]
ring.r += ring.speed
- ring.alpha -= 0.008
- if (ring.alpha <= 0 || ring.r > maxR) { RINGS.splice(i, 1); continue }
+ ring.alpha *= 0.982 // exponential fade — stays bright longer, fades smoothly
+ if (ring.alpha < 0.015 || ring.r > maxR) { RINGS.splice(i, 1); continue }
+
+ const lw = 2 + ring.alpha * 5
+
+ // Outer glow
+ ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
+ ctx.strokeStyle = `rgba(${a},${ring.alpha * 0.2})`
+ ctx.lineWidth = lw * 5; ctx.stroke()
// Main ring
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${a},${ring.alpha})`
- ctx.lineWidth = 1.5 + ring.alpha * 3.5; ctx.stroke()
+ ctx.lineWidth = lw; ctx.stroke()
- // Inner echo
- if (ring.r > 40 && ring.alpha > 0.15) {
- ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.80, 0, Math.PI * 2)
- ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.4})`
- ctx.lineWidth = 0.8; ctx.stroke()
+ // Pink echo
+ if (ring.r > 30 && ring.alpha > 0.08) {
+ ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.85, 0, Math.PI * 2)
+ ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.35})`
+ ctx.lineWidth = lw * 0.55; ctx.stroke()
}
}
- // Persistent center glow
- const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80 + smoothBass * 120)
- cg.addColorStop(0, `rgba(${a},${0.08 + smoothBass * 0.14})`); cg.addColorStop(1, `rgba(${a},0)`)
- ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
+ // Center dot — pulses with bass, small footprint so it doesn't drown rings
+ const dotR = 14 + smoothBass * 55
+ const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, dotR)
+ cg.addColorStop(0, `rgba(${a},${0.85 + smoothBass * 0.15})`)
+ cg.addColorStop(0.45,`rgba(${a},0.25)`)
+ cg.addColorStop(1, `rgba(${a},0)`)
+ ctx.fillStyle = cg
+ ctx.beginPath(); ctx.arc(cx, cy, dotR, 0, Math.PI * 2); ctx.fill()
}
// ── STARS ─────────────────────────────────────────────────────────────────
@@ -220,22 +255,24 @@ export default function AudioBackground() {
}))
const drawStars = () => {
const W = canvas.width, H = canvas.height, a = ac()
+ const cfg = fxRef.current.stars
const t = Date.now() / 1000
- ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
+ const bri = cfg.brightness
+ clear(W, H, cfg.trail)
const n1 = ctx.createRadialGradient(W*0.30, H*0.35, 0, W*0.30, H*0.35, W*0.55)
- n1.addColorStop(0, `rgba(${a},${0.04+smoothMid*0.04})`); n1.addColorStop(1, `rgba(${a},0)`)
+ n1.addColorStop(0, `rgba(${a},${(0.04+smoothMid*0.04)*bri})`); n1.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H)
for (const s of STARS) {
- const tw = (Math.sin(t * s.sp + s.ph) + 1) * 0.5
- const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4)
+ const tw = (Math.sin(t * s.sp * cfg.twinkle + s.ph) + 1) * 0.5
+ const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4) * bri
const r = s.r * (1 + smoothBass * tw * 1.0)
if (s.r > 1.1) { ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r*2.8, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha*0.18})`; ctx.fill() }
ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha})`; ctx.fill()
}
}
- // ── RAIN — sparse, soft streaks ───────────────────────────────────────────
- const DROPS: Drop[] = Array.from({ length: 30 }, () => ({
+ // ── RAIN ──────────────────────────────────────────────────────────────────
+ const DROPS: Drop[] = Array.from({ length: 60 }, () => ({
x: Math.random(), y: Math.random(),
speed: Math.random() * 0.0015 + 0.0008,
len: Math.random() * 0.055 + 0.03,
@@ -243,9 +280,12 @@ export default function AudioBackground() {
}))
const drawRain = () => {
const W = canvas.width, H = canvas.height, a = ac()
- ctx.fillStyle = 'rgba(10,10,15,0.12)'; ctx.fillRect(0, 0, W, H)
- const spd = 1 + smoothBass * 2.5
- for (const d of DROPS) {
+ const cfg = fxRef.current.rain
+ const count = Math.round(cfg.drops)
+ clear(W, H, cfg.trail)
+ const spd = (1 + smoothBass * 2.5) * cfg.speed
+ for (let i = 0; i < count && i < DROPS.length; i++) {
+ const d = DROPS[i]
d.y += d.speed * spd
if (d.y > 1.08) { d.y = -d.len - 0.02; d.x = Math.random() }
const x = d.x * W, y = d.y * H, len = d.len * H * (1 + smoothBass * 0.4)
@@ -256,34 +296,29 @@ export default function AudioBackground() {
}
}
- // ── RAYS — tapered beams from center ──────────────────────────────────────
+ // ── RAYS ──────────────────────────────────────────────────────────────────
const drawRays = () => {
const W = canvas.width, H = canvas.height, a = ac()
- const cfg = raysRef.current
+ const cfg = fxRef.current.rays
const t = (Date.now() / 1000) * cfg.speed * 0.08
const cx = W * 0.5, cy = H * 0.55
const maxR = Math.hypot(W, H) * 1.1
const br = cfg.brightness
const sp = cfg.spread
- ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
+ clear(W, H, cfg.trail)
const count = cfg.count
-
- // Primary rays — rotate forward
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + t
const isMain = i % 2 === 0
const hw = Math.tan((0.055 + smoothBass * 0.035) * sp) * maxR
const al = ((isMain ? 0.12 : 0.07) + smoothBass * 0.10 + smoothMid * 0.03) * br
- const ex = cx + Math.cos(angle) * maxR
- const ey = cy + Math.sin(angle) * maxR
- const px = -Math.sin(angle) * hw
- const py = Math.cos(angle) * hw
+ const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
+ const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
-
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
grad.addColorStop(0.12, `rgba(${a},${al})`)
@@ -292,17 +327,14 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill()
}
- // Secondary rays — counter-rotate, pink tint
const cnt2 = Math.max(2, Math.floor(count / 2))
for (let i = 0; i < cnt2; i++) {
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
const hw = Math.tan(0.035 * sp) * maxR
const al = (0.05 + smoothMid * 0.06) * br
- const ex = cx + Math.cos(angle) * maxR
- const ey = cy + Math.sin(angle) * maxR
- const px = -Math.sin(angle) * hw
- const py = Math.cos(angle) * hw
+ const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
+ const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
@@ -312,7 +344,6 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill()
}
- // Center glow
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 100 + smoothBass * 140)
cg.addColorStop(0, `rgba(${a},${(0.14 + smoothBass * 0.18) * br})`); cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
diff --git a/apps/web/src/components/BottomPlayer.tsx b/apps/web/src/components/BottomPlayer.tsx
index 77d86ff..1e4390e 100644
--- a/apps/web/src/components/BottomPlayer.tsx
+++ b/apps/web/src/components/BottomPlayer.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore'
@@ -9,6 +9,9 @@ import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState'
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'
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) {
- const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } =
- usePartyStore()
+ const {
+ queue, curIdx, loadKey, repeatMode,
+ updateQueueItemImg, setCurrentResults, setCurrentResult, setSearchStatus, searchStatus,
+ setCurIdx, setRepeatMode, removeFromQueue, addTrackToQueue,
+ } = usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
+ const showToast = useToastStore((s) => s.show)
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef(null)
@@ -43,7 +50,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
const [playlistOpen, setPlaylistOpen] = useState(false)
const playlistBtnRef = useRef(null)
- const dragSrcIdx = useRef(null)
const panelRef = useRef(null)
// Remote control
@@ -62,6 +68,9 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const prefetchInflightRef = useRef