Initial commit: party-mix-app with prefetch cache, audio preload optimizations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:40:22 +03:00
commit 0097fb5183
83 changed files with 11788 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
{
"permissions": {
"allow": [
"Bash(go mod *)",
"Bash(npm --version)",
"Bash(docker --version)",
"Bash(docker-compose build *)",
"Bash(npm install *)",
"Bash(docker-compose up *)",
"Bash(docker-compose -f \"D:\\\\!toyffee\\\\party-mix-app\\\\party-mix-app\\\\docker-compose.yml\" ps)",
"Bash(curl -s http://localhost:8081/health)",
"Bash(curl -s \"http://localhost:3001\" -o /dev/null -w \"Web HTTP status: %{http_code}\")",
"Bash(docker-compose stop *)",
"Bash(docker-compose rm *)",
"Bash(docker stop *)",
"Bash(docker rm *)",
"Bash(docker compose *)",
"Bash(curl *)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
"Bash(pg_isready)",
"Bash(where psql *)",
"Bash(powershell -command \"Get-Service 'com.docker.service' 2>/dev/null | Select-Object Status,Name\")",
"Bash(net start *)",
"Bash(powershell -command \"Start-Process 'C:\\\\Program Files\\\\Docker\\\\Docker\\\\Docker Desktop.exe' -WindowStyle Hidden\")",
"Bash(break)",
"Bash(xargs grep *)",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(f'Tracks: {len\\(d\\)}'\\); [print\\(f' {t[\\\\\"artist\\\\\"]} — {t[\\\\\"title\\\\\"]}'\\) for t in d[:5]]\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(repr\\(d[0]['artist'] + ' — ' + d[0]['title']\\)\\)\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); a=d[0]['artist']; print\\([hex\\(ord\\(c\\)\\) for c in a]\\)\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d[0], ensure_ascii=False, indent=2\\)\\)\")",
"Bash(npx tsc *)",
"Bash(go build *)",
"Bash(where go *)",
"Read(//c/Program Files/Go/**)",
"Read(//c/Go/**)",
"Bash(tasklist)",
"Bash(docker-compose down *)",
"Bash(docker-compose ps *)",
"Bash(xargs -I {} basename {})",
"Bash(docker-compose restart *)",
"Bash(Get-Content \"C:\\\\Users\\\\Kirko\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\D---toyffee-party-mix-app-party-mix-app\\\\b38c92ce-ec1f-4d1c-bb67-25a43c2d0293\\\\tasks\\\\blijpxwoy.output\" -Wait)",
"Bash(Select-String -Pattern \"Step|Successfully|ERROR|error|FAILED|=>|Building|writing\")",
"PowerShell(dir \"D:\\\\!toyffee\\\\party-mix-app\\\\party-mix-app\\\\apps\\\\backend\" -Recurse | Where-Object {!$_.PSIsContainer} | Select-Object FullName)",
"PowerShell(cmd /c \"dir /s /b D:\\\\!toyffee\\\\party-mix-app\\\\party-mix-app\\\\apps\\\\backend\")",
"PowerShell(Get-Content \"C:\\\\Users\\\\Kirko\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\D---toyffee-party-mix-app-party-mix-app\\\\b38c92ce-ec1f-4d1c-bb67-25a43c2d0293\\\\tasks\\\\bcqbr16en.output\" | Where-Object { $_ -match \"#13\" })",
"PowerShell(Get-Content *)",
"WebSearch",
"WebFetch(domain:yandex-music.readthedocs.io)",
"WebFetch(domain:github.com)",
"WebFetch(domain:ym.marshal.dev)",
"WebFetch(domain:music.yandex.ru)",
"WebFetch(domain:raw.githubusercontent.com)",
"PowerShell(Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 20 | Select-Object StatusCode, @{n='Body';e={$_.Content | ConvertFrom-Json | ConvertTo-Json -Depth 3}} 2>&1)",
"PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 20 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Content | ConvertFrom-Json | ForEach-Object { \"Name: $\\($_.name\\)\"; \"Tracks count: $\\($_.tracks.Count\\)\"; $_.tracks | Select-Object -First 5 })",
"PowerShell(docker build *)",
"PowerShell(Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 10 | Select-Object StatusCode 2>&1)",
"PowerShell(try { $r = Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 10; \"HTTP $\\($r.StatusCode\\)\" } catch { \"Error: $_\" }; docker inspect party-mix-app-backend-1 --format \"Created: {{.Created}}\" 2>&1)",
"PowerShell(docker image inspect *)",
"Bash(powershell -Command ' *)",
"Bash(git -C \"/d/!toyffee/party-mix-app/party-mix-app\" log --oneline -3)",
"Bash(docker run *)",
"Bash(docker exec *)",
"PowerShell(docker exec party-mix-app-web-1 *)"
]
}
}

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DB_PASSWORD=supersecretpassword
BOT_TOKEN=your_telegram_bot_token_here
NEXT_PUBLIC_API_URL=http://localhost:8080

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
* text=auto eol=lf
*.bat text eol=crlf

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
node_modules/
.next/
dist/
build/
*.log
postgres_data/

15
apps/backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod ./
ENV GOFLAGS=-mod=mod
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./main.go
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

14
apps/backend/go.mod Normal file
View File

@@ -0,0 +1,14 @@
module github.com/toyffee/party-mix
go 1.23
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.37.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)

View File

@@ -0,0 +1,37 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPass string
DBName string
JWTSecret string
}
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"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,39 @@
package database
import (
"fmt"
"log"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Connect(cfg *config.Config) *gorm.DB {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
cfg.DBHost, cfg.DBUser, cfg.DBPass, cfg.DBName, cfg.DBPort,
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
if err := db.AutoMigrate(
&models.Party{},
&models.Participant{},
&models.Track{},
&models.HistoryItem{},
&models.User{},
&models.Playlist{},
&models.PlaylistTrack{},
&models.UserVersion{},
); err != nil {
log.Fatalf("database migration failed: %v", err)
}
log.Println("Database connected and migrated")
return db
}

View File

@@ -0,0 +1,103 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
func Register(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
user := models.User{
ID: uuid.NewString(),
Username: req.Username,
Email: strings.ToLower(req.Email),
PasswordHash: string(hash),
CreatedAt: time.Now(),
}
if err := db.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "username or email already taken"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create user"})
return
}
c.JSON(http.StatusCreated, user)
}
}
type loginReq struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := db.Where("email = ?", strings.ToLower(req.Email)).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
token, err := generateToken(user.ID, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
}
}
func Me(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var user models.User
if err := db.First(&user, "id = ?", userID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
}

View File

@@ -0,0 +1,66 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type jwtClaims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
}
func generateToken(userID, secret string) (string, error) {
claims := jwtClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func parseToken(tokenStr, secret string) (*jwtClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &jwtClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*jwtClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func AuthRequired(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
tokenStr := strings.TrimPrefix(header, "Bearer ")
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, err := parseToken(tokenStr, jwtSecret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("userID", claims.UserID)
c.Next()
}
}
func currentUserID(c *gin.Context) string {
id, _ := c.Get("userID")
s, _ := id.(string)
return s
}

View File

@@ -0,0 +1,173 @@
package handlers
import (
"math/rand"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/gorm"
)
const codeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func generateCode() string {
b := make([]byte, 6)
for i := range b {
b[i] = codeChars[rand.Intn(len(codeChars))]
}
return string(b)
}
type createPartyReq struct {
Name string `json:"name" binding:"required"`
TelegramID int64 `json:"telegram_id"`
}
func CreateParty(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req createPartyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
party := models.Party{
ID: uuid.NewString(),
Name: req.Name,
Code: generateCode(),
TelegramID: req.TelegramID,
CreatedAt: time.Now(),
}
if err := db.Create(&party).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create party"})
return
}
c.JSON(http.StatusCreated, party)
}
}
func GetParty(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var party models.Party
err := db.Preload("Participants.Tracks").First(&party, "id = ?", c.Param("id")).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "party not found"})
return
}
c.JSON(http.StatusOK, party)
}
}
func GetPartyByCode(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var party models.Party
err := db.Preload("Participants.Tracks").First(&party, "code = ?", c.Param("code")).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "party not found"})
return
}
c.JSON(http.StatusOK, party)
}
}
type addParticipantReq struct {
Name string `json:"name" binding:"required"`
Tracks []string `json:"tracks" binding:"required"`
ColorBg string `json:"color_bg"`
ColorText string `json:"color_text"`
}
func AddParticipant(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req addParticipantReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
participant := models.Participant{
ID: uuid.NewString(),
PartyID: c.Param("id"),
Name: req.Name,
ColorBg: req.ColorBg,
ColorText: req.ColorText,
CreatedAt: time.Now(),
}
tracks := make([]models.Track, 0, len(req.Tracks))
for i, title := range req.Tracks {
tracks = append(tracks, models.Track{
ID: uuid.NewString(),
ParticipantID: participant.ID,
Title: title,
Position: i,
CreatedAt: time.Now(),
})
}
participant.Tracks = tracks
if err := db.Create(&participant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
return
}
c.JSON(http.StatusCreated, participant)
}
}
func RemoveParticipant(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
pid := c.Param("pid")
db.Where("participant_id = ?", pid).Delete(&models.Track{})
if err := db.Delete(&models.Participant{}, "id = ?", pid).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
return
}
c.Status(http.StatusNoContent)
}
}
type addHistoryReq struct {
Title string `json:"title"`
Artist string `json:"artist"`
ImgURL string `json:"img_url"`
OwnerName string `json:"owner_name"`
OwnerColorBg string `json:"owner_color_bg"`
OwnerColorText string `json:"owner_color_text"`
}
func AddHistory(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req addHistoryReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := models.HistoryItem{
ID: uuid.NewString(),
PartyID: c.Param("id"),
Title: req.Title,
Artist: req.Artist,
ImgURL: req.ImgURL,
OwnerName: req.OwnerName,
OwnerColorBg: req.OwnerColorBg,
OwnerColorText: req.OwnerColorText,
PlayedAt: time.Now(),
}
db.Create(&item)
c.JSON(http.StatusCreated, item)
}
}
func GetHistory(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var items []models.HistoryItem
db.Where("party_id = ?", c.Param("id")).Order("played_at desc").Limit(100).Find(&items)
c.JSON(http.StatusOK, items)
}
}
func ClearHistory(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
db.Where("party_id = ?", c.Param("id")).Delete(&models.HistoryItem{})
c.Status(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,283 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/gorm"
)
func parseTags(s string) []string {
if s == "" {
return []string{}
}
result := []string{}
for _, t := range strings.Split(s, ",") {
if t = strings.TrimSpace(t); t != "" {
result = append(result, t)
}
}
return result
}
func joinTags(tags []string) string {
cleaned := make([]string, 0, len(tags))
for _, t := range tags {
if t = strings.TrimSpace(t); t != "" {
cleaned = append(cleaned, t)
}
}
return strings.Join(cleaned, ",")
}
type playlistResp struct {
models.Playlist
Tags []string `json:"tags"`
}
type pubPlaylistResp struct {
models.Playlist
Tags []string `json:"tags"`
Username string `json:"username"`
}
func toResp(p models.Playlist) playlistResp {
return playlistResp{Playlist: p, Tags: parseTags(p.Tags)}
}
func GetPublicPlaylists(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var playlists []models.Playlist
db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
return tx.Order("position asc")
}).Where("is_public = ?", true).Order("created_at desc").Find(&playlists)
type UserRow struct {
ID string
Username string
}
userIDs := make([]string, 0, len(playlists))
for _, p := range playlists {
userIDs = append(userIDs, p.UserID)
}
var users []UserRow
db.Model(&models.User{}).Select("id, username").Where("id IN ?", userIDs).Find(&users)
userMap := make(map[string]string, len(users))
for _, u := range users {
userMap[u.ID] = u.Username
}
resp := make([]pubPlaylistResp, 0, len(playlists))
for _, p := range playlists {
resp = append(resp, pubPlaylistResp{
Playlist: p,
Tags: parseTags(p.Tags),
Username: userMap[p.UserID],
})
}
c.JSON(http.StatusOK, resp)
}
}
func GetPlaylists(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlists []models.Playlist
db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
return tx.Order("position asc")
}).Where("user_id = ?", userID).Order("created_at desc").Find(&playlists)
resp := make([]playlistResp, 0, len(playlists))
for _, p := range playlists {
resp = append(resp, toResp(p))
}
c.JSON(http.StatusOK, resp)
}
}
type playlistTrackInput struct {
Title string `json:"title"`
Position int `json:"position"`
}
type playlistReq struct {
Name string `json:"name" binding:"required"`
Tracks []playlistTrackInput `json:"tracks"`
IsPublic bool `json:"is_public"`
Tags []string `json:"tags"`
}
func CreatePlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req playlistReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
playlist := models.Playlist{
ID: uuid.NewString(),
UserID: currentUserID(c),
Name: req.Name,
IsPublic: req.IsPublic,
Tags: joinTags(req.Tags),
CreatedAt: time.Now(),
}
tracks := buildTracks(playlist.ID, req.Tracks)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&playlist).Error; err != nil {
return err
}
if len(tracks) > 0 {
return tx.Create(&tracks).Error
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create playlist"})
return
}
playlist.Tracks = tracks
c.JSON(http.StatusCreated, toResp(playlist))
}
}
func GetPlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
return tx.Order("position asc")
}).First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.JSON(http.StatusOK, toResp(playlist))
}
}
func UpdatePlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var req playlistReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
playlist.Name = req.Name
playlist.IsPublic = req.IsPublic
playlist.Tags = joinTags(req.Tags)
newTracks := buildTracks(playlist.ID, req.Tracks)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&playlist).Error; err != nil {
return err
}
if err := tx.Where("playlist_id = ?", playlist.ID).Delete(&models.PlaylistTrack{}).Error; err != nil {
return err
}
if len(newTracks) > 0 {
return tx.Create(&newTracks).Error
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not update playlist"})
return
}
playlist.Tracks = newTracks
c.JSON(http.StatusOK, toResp(playlist))
}
}
func AddTrackToPlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.Preload("Tracks").First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var req struct {
Title string `json:"title" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
track := models.PlaylistTrack{
ID: uuid.NewString(),
PlaylistID: playlist.ID,
Title: req.Title,
Position: len(playlist.Tracks),
CreatedAt: time.Now(),
}
if err := db.Create(&track).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not add track"})
return
}
c.JSON(http.StatusCreated, track)
}
}
func DeletePlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
db.Where("playlist_id = ?", playlist.ID).Delete(&models.PlaylistTrack{})
db.Delete(&playlist)
c.Status(http.StatusNoContent)
}
}
func buildTracks(playlistID string, inputs []playlistTrackInput) []models.PlaylistTrack {
tracks := make([]models.PlaylistTrack, 0, len(inputs))
for i, t := range inputs {
if t.Title == "" {
continue
}
pos := t.Position
if pos == 0 {
pos = i
}
tracks = append(tracks, models.PlaylistTrack{
ID: uuid.NewString(),
PlaylistID: playlistID,
Title: t.Title,
Position: pos,
CreatedAt: time.Now(),
})
}
return tracks
}

View File

@@ -0,0 +1,432 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
var (
httpClient = &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// No body-read timeout: MP3 files are large and stream takes time.
// DisableCompression=true prevents Go from auto-decompressing gzip while
// forwarding the upstream's Content-Length (compressed size), which would
// cause ERR_CONTENT_LENGTH_MISMATCH in the browser.
mp3StreamClient = &http.Client{
Timeout: 5 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DisableCompression: true,
},
}
dlRegex = regexp.MustCompile(`href="(/get/music/[^"]+\.mp3)"`)
metaRegex = regexp.MustCompile(`data-musmeta='([^']+)'`)
durRegex = regexp.MustCompile(`track__time[^>]*>([^<\s][^<]*)`)
titleRegex = regexp.MustCompile(`track__title[^>]*>([^<]+)<`)
artistRegex = regexp.MustCompile(`track__desc[^>]*>([^<]+)<`)
)
// Search result cache
type searchCacheEntry struct {
results []SearchResult
expiry time.Time
}
var (
searchCacheMu sync.RWMutex
searchCacheMap = map[string]*searchCacheEntry{}
)
func cacheKey(q string) string {
return strings.ToLower(strings.TrimSpace(q))
}
func getSearchCache(q string) ([]SearchResult, bool) {
key := cacheKey(q)
searchCacheMu.RLock()
e, ok := searchCacheMap[key]
searchCacheMu.RUnlock()
if !ok || time.Now().After(e.expiry) {
return nil, false
}
return e.results, true
}
func setSearchCache(q string, results []SearchResult) {
key := cacheKey(q)
searchCacheMu.Lock()
searchCacheMap[key] = &searchCacheEntry{results: results, expiry: time.Now().Add(30 * time.Minute)}
if len(searchCacheMap) > 1000 {
now := time.Now()
for k, v := range searchCacheMap {
if now.After(v.expiry) {
delete(searchCacheMap, k)
}
}
}
searchCacheMu.Unlock()
}
type musMeta struct {
Title string `json:"title"`
Artist string `json:"artist"`
Img string `json:"img"`
}
type SearchResult struct {
MP3 string `json:"mp3"`
Title string `json:"title"`
Artist string `json:"artist"`
Duration string `json:"duration"`
Img string `json:"img"`
}
func hitmotopHeaders(req *http.Request) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9")
req.Header.Set("Referer", "https://rus.hitmotop.com/")
req.Header.Set("Origin", "https://rus.hitmotop.com")
}
func TopChartsHandler(c *gin.Context) {
targetURL := "https://rus.hitmotop.com/"
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
return
}
hitmotopHeaders(req)
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "fetch failed"})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
results := parseSearchResults(string(body))
if len(results) > 20 {
results = results[:20]
}
c.Header("Cache-Control", "public, max-age=600")
c.JSON(http.StatusOK, results)
}
func SearchHandler(c *gin.Context) {
q := c.Query("q")
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"})
return
}
if cached, ok := getSearchCache(q); ok {
c.JSON(http.StatusOK, cached)
return
}
targetURL := fmt.Sprintf("https://rus.hitmotop.com/search?q=%s", url.QueryEscape(q))
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
return
}
hitmotopHeaders(req)
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search request failed"})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
results := parseSearchResults(string(body))
setSearchCache(q, results)
c.JSON(http.StatusOK, results)
}
func parseSearchResults(html string) []SearchResult {
dlMatches := dlRegex.FindAllStringSubmatch(html, -1)
metaMatches := metaRegex.FindAllStringSubmatch(html, -1)
results := make([]SearchResult, 0, len(dlMatches))
for i, dm := range dlMatches {
if len(dm) < 2 {
continue
}
href := dm[1]
originalMp3 := "https://rus.hitmotop.com" + href
mp3 := "/api/proxy/mp3?url=" + url.QueryEscape(originalMp3)
var meta musMeta
if i < len(metaMatches) && len(metaMatches[i]) >= 2 {
_ = json.Unmarshal([]byte(metaMatches[i][1]), &meta)
}
hrefPos := strings.Index(html, href)
chunk := ""
if hrefPos >= 0 {
start := hrefPos - 800
if start < 0 {
start = 0
}
end := hrefPos + 200
if end > len(html) {
end = len(html)
}
chunk = html[start:end]
}
dur := ""
if m := durRegex.FindStringSubmatch(chunk); len(m) >= 2 {
dur = strings.TrimSpace(m[1])
}
title := meta.Title
if title == "" {
if m := titleRegex.FindStringSubmatch(chunk); len(m) >= 2 {
title = strings.TrimSpace(m[1])
}
}
artist := meta.Artist
if artist == "" {
if m := artistRegex.FindStringSubmatch(chunk); len(m) >= 2 {
artist = strings.TrimSpace(m[1])
}
}
results = append(results, SearchResult{
MP3: mp3,
Title: title,
Artist: artist,
Duration: dur,
Img: meta.Img,
})
}
return results
}
func ImgProxyHandler(c *gin.Context) {
imgURL := c.Query("url")
if !strings.HasPrefix(imgURL, "http") {
c.Status(http.StatusBadRequest)
return
}
req, err := http.NewRequest("GET", imgURL, nil)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Referer", "https://rus.hitmotop.com/")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "image/jpeg"
}
c.Header("Cache-Control", "public, max-age=86400")
c.Header("Content-Type", ct)
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
func MP3ProxyHandler(c *gin.Context) {
mp3URL := c.Query("url")
if !strings.HasPrefix(mp3URL, "https://rus.hitmotop.com/") &&
!strings.HasPrefix(mp3URL, "https://hitmotop.com/") {
c.JSON(http.StatusForbidden, gin.H{"error": "only hitmotop URLs allowed"})
return
}
rangeHeader := c.GetHeader("Range")
fetchMP3(c, mp3URL, rangeHeader, 0)
}
type yandexArtist struct {
Name string `json:"name"`
}
type yandexTrackInfo struct {
Title string `json:"title"`
Artists []yandexArtist `json:"artists"`
}
func YandexPlaylistHandler(c *gin.Context) {
rawURL := c.Query("url")
if rawURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
u, err := url.Parse(rawURL)
if err != nil || !strings.Contains(u.Host, "yandex") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid yandex music url"})
return
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
var playlistUUID string
if len(parts) >= 2 && parts[0] == "playlists" {
playlistUUID = parts[1]
}
if playlistUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported url, use share link from Яндекс.Музыка"})
return
}
apiURL := "https://api.music.yandex.net/playlist/" + url.PathEscape(playlistUUID)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("X-Yandex-Music-Client", "WindowsPhone/3.20")
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "yandex unreachable"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("yandex returned %d", resp.StatusCode)})
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
var apiResp struct {
Result struct {
Title string `json:"title"`
Tracks []struct {
Track *yandexTrackInfo `json:"track,omitempty"`
Title string `json:"title,omitempty"`
Artists []yandexArtist `json:"artists,omitempty"`
} `json:"tracks"`
} `json:"result"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse failed"})
return
}
tracks := make([]string, 0, len(apiResp.Result.Tracks))
for _, t := range apiResp.Result.Tracks {
var info *yandexTrackInfo
if t.Track != nil {
info = t.Track
} else if t.Title != "" {
info = &yandexTrackInfo{Title: t.Title, Artists: t.Artists}
}
if info == nil || info.Title == "" {
continue
}
trackStr := info.Title
if len(info.Artists) > 0 && info.Artists[0].Name != "" {
trackStr = info.Artists[0].Name + " — " + info.Title
}
tracks = append(tracks, trackStr)
}
c.JSON(http.StatusOK, gin.H{
"name": apiResp.Result.Title,
"tracks": tracks,
})
}
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
if redirectCount > 5 {
c.Status(http.StatusInternalServerError)
return
}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
hitmotopHeaders(req)
req.Header.Set("Accept", "audio/mpeg, audio/*, */*")
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := mp3StreamClient.Do(req)
if err != nil {
if !c.Writer.Written() {
c.Status(http.StatusInternalServerError)
}
return
}
defer resp.Body.Close()
if resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 307 || resp.StatusCode == 308 {
location := resp.Header.Get("Location")
if location == "" {
c.Status(http.StatusInternalServerError)
return
}
if !strings.HasPrefix(location, "http") {
parsed, _ := url.Parse(targetURL)
location = "https://" + parsed.Host + location
}
fetchMP3(c, location, rangeHeader, redirectCount+1)
return
}
c.Header("Content-Type", "audio/mpeg")
for _, h := range []string{"Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
c.Header("Cache-Control", "public, max-age=300")
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}

View File

@@ -0,0 +1,162 @@
package handlers
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RemoteQueueItem struct {
Title string `json:"title"`
Owner string `json:"owner"`
ColorBg string `json:"color_bg"`
ColorText string `json:"color_text"`
Img string `json:"img,omitempty"`
}
type RemoteVersion struct {
Title string `json:"title"`
Artist string `json:"artist"`
Duration string `json:"duration"`
Img string `json:"img,omitempty"`
}
type RemoteState struct {
Title string `json:"title"`
Artist string `json:"artist"`
Cover string `json:"cover"`
IsPlaying bool `json:"is_playing"`
Volume float64 `json:"volume"`
Progress float64 `json:"progress"`
Duration float64 `json:"duration"`
QueueLen int `json:"queue_len"`
CurIdx int `json:"cur_idx"`
Queue []RemoteQueueItem `json:"queue,omitempty"`
Versions []RemoteVersion `json:"versions,omitempty"`
ActiveVersion int `json:"active_version"`
}
type RemoteCommand struct {
ID string `json:"id"`
Cmd string `json:"cmd"`
Value float64 `json:"value,omitempty"`
Text string `json:"text,omitempty"`
}
type remoteRoom struct {
state RemoteState
commands []RemoteCommand
lastSeen time.Time
mu sync.Mutex
}
var (
remoteMu sync.RWMutex
remoteRooms = map[string]*remoteRoom{}
)
func init() {
go func() {
for {
time.Sleep(30 * time.Minute)
remoteMu.Lock()
for id, r := range remoteRooms {
if time.Since(r.lastSeen) > 4*time.Hour {
delete(remoteRooms, id)
}
}
remoteMu.Unlock()
}
}()
}
func CreateRemoteRoom(c *gin.Context) {
id := uuid.NewString()[:8]
remoteMu.Lock()
remoteRooms[id] = &remoteRoom{lastSeen: time.Now()}
remoteMu.Unlock()
c.JSON(http.StatusOK, gin.H{"id": id})
}
func PushRemoteState(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var s RemoteState
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
room.mu.Lock()
room.state = s
room.lastSeen = time.Now()
room.mu.Unlock()
c.Status(http.StatusNoContent)
}
func GetRemoteState(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
room.mu.Lock()
s := room.state
room.mu.Unlock()
c.JSON(http.StatusOK, s)
}
func SendRemoteCommand(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var cmd RemoteCommand
if err := c.ShouldBindJSON(&cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cmd.ID = uuid.NewString()
room.mu.Lock()
room.commands = append(room.commands, cmd)
if len(room.commands) > 50 {
room.commands = room.commands[len(room.commands)-50:]
}
room.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func PollRemoteCommands(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
room.mu.Lock()
cmds := room.commands
room.commands = nil
room.lastSeen = time.Now()
room.mu.Unlock()
if cmds == nil {
cmds = []RemoteCommand{}
}
c.JSON(http.StatusOK, cmds)
}

View File

@@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/gorm"
)
func GetVersions(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var rows []models.UserVersion
if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make(map[string]gin.H, len(rows))
for _, r := range rows {
result[r.TrackTitle] = gin.H{
"title": r.VersionTitle,
"artist": r.VersionArtist,
"duration": r.VersionDuration,
}
}
c.JSON(http.StatusOK, result)
}
}
type saveVersionReq struct {
TrackTitle string `json:"track_title" binding:"required"`
VersionTitle string `json:"title" binding:"required"`
VersionArtist string `json:"artist"`
VersionDuration string `json:"duration"`
}
func SaveVersion(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var req saveVersionReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var row models.UserVersion
err := db.Where("user_id = ? AND track_title = ?", userID, req.TrackTitle).First(&row).Error
if err != nil {
row = models.UserVersion{
ID: uuid.NewString(),
UserID: userID,
TrackTitle: req.TrackTitle,
CreatedAt: time.Now(),
}
}
row.VersionTitle = req.VersionTitle
row.VersionArtist = req.VersionArtist
row.VersionDuration = req.VersionDuration
if err := db.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
type deleteVersionReq struct {
TrackTitle string `json:"track_title" binding:"required"`
}
func DeleteVersion(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var req deleteVersionReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db.Where("user_id = ? AND track_title = ?", userID, req.TrackTitle).Delete(&models.UserVersion{})
c.Status(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,79 @@
package models
import "time"
type Party struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Name string `gorm:"not null" json:"name"`
Code string `gorm:"uniqueIndex;not null" json:"code"`
TelegramID int64 `json:"telegram_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Participants []Participant `gorm:"foreignKey:PartyID" json:"participants,omitempty"`
History []HistoryItem `gorm:"foreignKey:PartyID" json:"history,omitempty"`
}
type Participant struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
PartyID string `gorm:"not null;index" json:"party_id"`
Name string `gorm:"not null" json:"name"`
ColorBg string `json:"color_bg"`
ColorText string `json:"color_text"`
CreatedAt time.Time `json:"created_at"`
Tracks []Track `gorm:"foreignKey:ParticipantID" json:"tracks,omitempty"`
}
type Track struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
ParticipantID string `gorm:"not null;index" json:"participant_id"`
Title string `gorm:"not null" json:"title"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
}
type HistoryItem struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
PartyID string `gorm:"not null;index" json:"party_id"`
Title string `json:"title"`
Artist string `json:"artist"`
ImgURL string `json:"img_url"`
OwnerName string `json:"owner_name"`
OwnerColorBg string `json:"owner_color_bg"`
OwnerColorText string `json:"owner_color_text"`
PlayedAt time.Time `json:"played_at"`
}
type User struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Playlist struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
IsPublic bool `gorm:"default:false" json:"is_public"`
Tags string `gorm:"default:''" json:"-"`
CreatedAt time.Time `json:"created_at"`
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
}
type PlaylistTrack struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
PlaylistID string `gorm:"not null;index" json:"playlist_id"`
Title string `gorm:"not null" json:"title"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
}
type UserVersion struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index:idx_user_version_track,unique" json:"user_id"`
TrackTitle string `gorm:"not null;index:idx_user_version_track,unique" json:"track_title"`
VersionTitle string `gorm:"not null" json:"version_title"`
VersionArtist string `gorm:"not null" json:"version_artist"`
VersionDuration string `gorm:"not null" json:"version_duration"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,84 @@
package router
import (
"net/http"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/toyffee/party-mix/internal/handlers"
"gorm.io/gorm"
)
func New(db *gorm.DB, jwtSecret string) *gin.Engine {
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
AllowCredentials: false,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
proxy := r.Group("/api/proxy")
{
proxy.GET("/search", handlers.SearchHandler)
proxy.GET("/top", handlers.TopChartsHandler)
proxy.GET("/img", handlers.ImgProxyHandler)
proxy.GET("/mp3", handlers.MP3ProxyHandler)
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
}
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))
}
r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db))
versions := r.Group("/api/versions", handlers.AuthRequired(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.GET("", handlers.GetPlaylists(db))
playlists.POST("", handlers.CreatePlaylist(db))
playlists.GET("/:id", handlers.GetPlaylist(db))
playlists.PUT("/:id", handlers.UpdatePlaylist(db))
playlists.DELETE("/:id", handlers.DeletePlaylist(db))
playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db))
}
remote := r.Group("/api/remote")
{
remote.POST("", handlers.CreateRemoteRoom)
remote.PUT("/:id/state", handlers.PushRemoteState)
remote.GET("/:id/state", handlers.GetRemoteState)
remote.POST("/:id/command", handlers.SendRemoteCommand)
remote.GET("/:id/commands", handlers.PollRemoteCommands)
}
parties := r.Group("/api/parties")
{
parties.POST("", handlers.CreateParty(db))
parties.GET("/:id", handlers.GetParty(db))
parties.GET("/code/:code", handlers.GetPartyByCode(db))
parties.POST("/:id/participants", handlers.AddParticipant(db))
parties.DELETE("/:id/participants/:pid", handlers.RemoveParticipant(db))
parties.POST("/:id/history", handlers.AddHistory(db))
parties.GET("/:id/history", handlers.GetHistory(db))
parties.DELETE("/:id/history", handlers.ClearHistory(db))
}
return r
}

19
apps/backend/main.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"log"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/database"
"github.com/toyffee/party-mix/internal/router"
)
func main() {
cfg := config.Load()
db := database.Connect(cfg)
r := router.New(db, cfg.JWTSecret)
log.Printf("Party Mix backend starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err)
}
}

13
apps/bot/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

983
apps/bot/package-lock.json generated Normal file
View File

@@ -0,0 +1,983 @@
{
"name": "party-mix-bot",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "party-mix-bot",
"version": "1.0.0",
"dependencies": {
"axios": "^1.7.2",
"grammy": "^1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@grammyjs/types": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz",
"integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/grammy": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz",
"integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==",
"license": "MIT",
"dependencies": {
"@grammyjs/types": "3.26.0",
"abort-controller": "^3.0.0",
"debug": "^4.4.3",
"node-fetch": "^2.7.0"
},
"engines": {
"node": "^12.20.0 || >=14.13.1"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}

19
apps/bot/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "party-mix-bot",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"grammy": "^1",
"axios": "^1.7.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,54 @@
import axios from 'axios'
import { config } from '../config'
import type { Party, Participant } from '../types'
const api = axios.create({ baseURL: config.backendUrl, timeout: 10_000 })
export async function createParty(name: string, telegramId: number): Promise<Party> {
const { data } = await api.post<Party>('/api/parties', { name, telegram_id: telegramId })
return data
}
export async function getPartyByCode(code: string): Promise<Party | null> {
try {
const { data } = await api.get<Party>(`/api/parties/code/${code}`)
return data
} catch {
return null
}
}
export async function getParty(id: string): Promise<Party | null> {
try {
const { data } = await api.get<Party>(`/api/parties/${id}`)
return data
} catch {
return null
}
}
const COLORS = [
{ bg: 'rgba(200,255,0,0.1)', text: '#c8ff00' },
{ bg: 'rgba(255,60,172,0.1)', text: '#ff3cac' },
{ bg: 'rgba(0,212,255,0.1)', text: '#00d4ff' },
{ bg: 'rgba(255,165,0,0.1)', text: '#ffa500' },
{ bg: 'rgba(140,100,255,0.1)', text: '#8c64ff' },
{ bg: 'rgba(0,255,140,0.1)', text: '#00ff8c' },
{ bg: 'rgba(255,80,80,0.1)', text: '#ff5050' },
]
export async function addParticipant(
partyId: string,
name: string,
tracks: string[],
colorIndex: number,
): Promise<Participant> {
const color = COLORS[colorIndex % COLORS.length]
const { data } = await api.post<Participant>(`/api/parties/${partyId}/participants`, {
name,
tracks,
color_bg: color.bg,
color_text: color.text,
})
return data
}

46
apps/bot/src/bot.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Bot, session } from 'grammy'
import { config } from './config'
import type { SessionData } from './types'
import { handleStart } from './commands/start'
import { handleHelp } from './commands/help'
import {
handleNewParty,
handleJoinParty,
handleAddMe,
handlePartyInfo,
handleTracksMessage,
} from './commands/party'
type SessionContext = Parameters<typeof handleTracksMessage>[0]
function createBot() {
const bot = new Bot<SessionContext>(config.botToken)
bot.use(
session({
initial: (): SessionData => ({}),
}),
)
bot.command('start', handleStart)
bot.command('help', handleHelp)
bot.command('newparty', handleNewParty)
bot.command('joinparty', handleJoinParty)
bot.command('addme', handleAddMe)
bot.command('party', handlePartyInfo)
bot.on('message:text', async (ctx) => {
const handled = await handleTracksMessage(ctx)
if (!handled) {
await ctx.reply('Неизвестная команда. /help для справки.')
}
})
bot.catch((err) => {
console.error('Bot error:', err)
})
return bot
}
export default createBot

View File

@@ -0,0 +1,17 @@
import { CommandContext, Context } from 'grammy'
export async function handleHelp(ctx: CommandContext<Context>) {
await ctx.reply(
`*Как использовать Party Mix:*\n\n` +
`1\\. Создай вечеринку: /newparty Моя вечеринка\n` +
`2\\. Поделись кодом с друзьями\n` +
`3\\. Каждый добавляет себя: /addme Имя\n` +
`4\\. После \\— список треков строчкой за строчкой\n` +
`5\\. Открой веб\\-ссылку и нажми *Перемешать*\\!\n\n` +
`*Формат треков:*\n` +
`\`Исполнитель — Название\`\n` +
`\`Тараканы — Пойдём на улицу\`\n` +
`\`PALC — Залип\``,
{ parse_mode: 'MarkdownV2' },
)
}

View File

@@ -0,0 +1,152 @@
import { CommandContext, Context } from 'grammy'
import { createParty, getPartyByCode, addParticipant, getParty } from '../api/client'
import type { SessionData } from '../types'
type SessionContext = Context & { session: SessionData }
export async function handleNewParty(ctx: CommandContext<SessionContext>) {
const name = ctx.match?.trim() || `Вечеринка ${new Date().toLocaleDateString('ru')}`
const telegramId = ctx.from?.id ?? 0
try {
const party = await createParty(name, telegramId)
ctx.session.partyId = party.id
ctx.session.partyCode = party.code
ctx.session.partyName = party.name
await ctx.reply(
`🎉 Вечеринка *${escapeMarkdown(party.name)}* создана\\!\n\n` +
`🔑 Код: \`${party.code}\`\n\n` +
`Поделись кодом с друзьями — они введут /joinparty ${party.code}\n\n` +
`Затем каждый добавляет себя командой /addme Имя`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при создании вечеринки. Попробуй ещё раз.')
}
}
export async function handleJoinParty(ctx: CommandContext<SessionContext>) {
const code = ctx.match?.trim().toUpperCase()
if (!code) {
await ctx.reply('Укажи код: /joinparty КОД')
return
}
try {
const party = await getPartyByCode(code)
if (!party) {
await ctx.reply(`Вечеринка с кодом \`${code}\` не найдена.`, { parse_mode: 'MarkdownV2' })
return
}
ctx.session.partyId = party.id
ctx.session.partyCode = party.code
ctx.session.partyName = party.name
const count = party.participants?.length ?? 0
await ctx.reply(
`✅ Подключился к вечеринке *${escapeMarkdown(party.name)}*\\!\n` +
`Участников: ${count}\n\n` +
`Добавь себя: /addme Имя`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при подключении. Проверь код и попробуй снова.')
}
}
export async function handleAddMe(ctx: CommandContext<SessionContext>) {
const name = ctx.match?.trim()
if (!name) {
await ctx.reply('Укажи своё имя: /addme Твоё Имя')
return
}
if (!ctx.session.partyId) {
await ctx.reply('Сначала создай или присоединись к вечеринке: /newparty или /joinparty КОД')
return
}
ctx.session.awaitingTracks = true
ctx.session.pendingParticipantName = name
await ctx.reply(
`Отлично, *${escapeMarkdown(name)}*\\! 🎵\n\n` +
`Отправь список своих треков — каждый с новой строки:\n\n` +
`\`Тараканы — Пойдём на улицу\n` +
`PALC — Залип\n` +
`Дора — Втюрилась\``,
{ parse_mode: 'MarkdownV2' },
)
}
export async function handlePartyInfo(ctx: CommandContext<SessionContext>) {
if (!ctx.session.partyId) {
await ctx.reply('У тебя нет активной вечеринки. Создай: /newparty или присоединись: /joinparty КОД')
return
}
try {
const party = await getParty(ctx.session.partyId)
if (!party) {
ctx.session.partyId = undefined
await ctx.reply('Вечеринка не найдена. Возможно она была удалена.')
return
}
const participants = party.participants ?? []
const list = participants.length
? participants.map((p) => `${p.name}${p.tracks?.length ?? 0} тр\\.`).join('\n')
: ' _Пока никого_'
await ctx.reply(
`🎉 *${escapeMarkdown(party.name)}*\n` +
`Код: \`${party.code}\`\n\n` +
`*Участники:*\n${list}`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при получении информации о вечеринке.')
}
}
export async function handleTracksMessage(ctx: SessionContext) {
if (!ctx.session.awaitingTracks || !ctx.session.pendingParticipantName || !ctx.session.partyId) {
return false
}
const text = ctx.message?.text ?? ''
const tracks = text
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 1)
.slice(0, 50)
if (!tracks.length) {
await ctx.reply('Не нашёл треков. Пришли список заново.')
return true
}
try {
const party = await getParty(ctx.session.partyId)
const colorIndex = party?.participants?.length ?? 0
await addParticipant(ctx.session.partyId, ctx.session.pendingParticipantName, tracks, colorIndex)
ctx.session.awaitingTracks = false
const name = ctx.session.pendingParticipantName
ctx.session.pendingParticipantName = undefined
await ctx.reply(
`✅ *${escapeMarkdown(name)}* добавлен с ${tracks.length} треками\\!\n\n` +
`Используй /party чтобы посмотреть всех участников\\.`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при добавлении участника. Попробуй ещё раз.')
}
return true
}
function escapeMarkdown(text: string): string {
return text.replace(/[_*[\]()~`>#+=|{}.!\\-]/g, '\\$&')
}

View File

@@ -0,0 +1,14 @@
import { CommandContext, Context } from 'grammy'
export async function handleStart(ctx: CommandContext<Context>) {
await ctx.reply(
`🎉 *Party Mix Bot*\n\nСоздавай вечеринки с общими плейлистами!\n\n` +
`*Команды:*\n` +
`/newparty \\[название\\] — создать новую вечеринку\n` +
`/joinparty \\[код\\] — присоединиться к вечеринке\n` +
`/addme \\[имя\\] — добавить себя как участника\n` +
`/party — информация о текущей вечеринке\n` +
`/help — показать эту справку`,
{ parse_mode: 'MarkdownV2' },
)
}

8
apps/bot/src/config.ts Normal file
View File

@@ -0,0 +1,8 @@
export const config = {
botToken: process.env.BOT_TOKEN ?? '',
backendUrl: process.env.BACKEND_URL ?? 'http://localhost:8080',
} as const
if (!config.botToken) {
throw new Error('BOT_TOKEN environment variable is required')
}

12
apps/bot/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import createBot from './bot'
const bot = createBot()
process.once('SIGINT', () => bot.stop())
process.once('SIGTERM', () => bot.stop())
bot.start({
onStart: (info) => {
console.log(`Party Mix Bot started: @${info.username}`)
},
})

View File

@@ -0,0 +1,32 @@
export interface Party {
id: string
name: string
code: string
telegram_id?: number
created_at: string
participants?: Participant[]
}
export interface Participant {
id: string
party_id: string
name: string
color_bg: string
color_text: string
tracks?: Track[]
}
export interface Track {
id: string
participant_id: string
title: string
position: number
}
export interface SessionData {
partyId?: string
partyCode?: string
partyName?: string
awaitingTracks?: boolean
pendingParticipantName?: string
}

15
apps/bot/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

23
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
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
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

13
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'rus.hitmotop.com' },
{ protocol: 'https', hostname: 'hitmotop.com' },
],
},
}
export default nextConfig

2186
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "party-mix-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config

1
apps/web/public/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,29 @@
'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import Header from '@/components/Header'
import Tabs from '@/components/Tabs'
import type { Tab } from '@/components/Tabs'
import PartyTab from '@/components/PartyTab/PartyTab'
import HistoryTab from '@/components/HistoryTab/HistoryTab'
import ExtraTab from '@/components/ExtraTab/ExtraTab'
export default function Home() {
const [activeTab, setActiveTab] = useState<Tab>('party')
const { history } = usePartyStore()
return (
<main className="max-w-app mx-auto relative z-10">
<Header />
<Tabs
active={activeTab}
historyCount={history.length}
onSwitch={setActiveTab}
/>
{activeTab === 'party' && <PartyTab />}
{activeTab === 'extra' && <ExtraTab />}
{activeTab === 'history' && <HistoryTab />}
</main>
)
}

View File

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

View File

@@ -0,0 +1,146 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--border: rgba(255, 255, 255, 0.07);
--accent: #c8ff00;
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
/* ── Audio element ── */
audio {
width: 100%;
height: 34px;
border-radius: 8px;
outline: none;
filter: invert(1) hue-rotate(180deg) saturate(0.5);
}
/* ── EQ bars (now playing indicator) ── */
@keyframes eqAnim {
from {
transform: scaleY(0.3);
}
to {
transform: scaleY(1);
}
}
.eq-bar {
width: 2.5px;
border-radius: 2px;
background: #c8ff00;
animation: eqAnim 0.7s ease-in-out infinite alternate;
height: 100%;
}
.eq-bar:nth-child(2) {
animation-delay: 0.15s;
height: 60%;
}
.eq-bar:nth-child(3) {
animation-delay: 0.3s;
height: 80%;
}
.eq-bar:nth-child(4) {
animation-delay: 0.1s;
height: 50%;
}
/* ── Queue bars (playing indicator) ── */
@keyframes barAnim {
from {
height: 2px;
}
to {
height: 11px;
}
}
.queue-bar {
width: 2.5px;
border-radius: 1px;
background: #c8ff00;
animation: barAnim 0.7s ease-in-out infinite alternate;
}
.queue-bar:nth-child(2) {
animation-delay: 0.15s;
}
.queue-bar:nth-child(3) {
animation-delay: 0.3s;
}
/* ── Cover glow ── */
@keyframes glowPulse {
from {
opacity: 0.4;
}
to {
opacity: 0.9;
}
}
.cover-glow {
position: absolute;
inset: -20px;
border-radius: 50%;
background: radial-gradient(circle, var(--glow-color, rgba(200, 255, 0, 0.25)) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.5s;
filter: blur(12px);
pointer-events: none;
z-index: 0;
}
.cover-playing .cover-glow {
opacity: 1;
animation: glowPulse 1.5s ease-in-out infinite alternate;
}
/* ── Cover pulse ── */
@keyframes coverPulse {
from {
transform: scale(1);
}
to {
transform: scale(1.06);
}
}
.cover-img-playing {
animation: coverPulse 2s ease-in-out infinite alternate;
}
/* ── Drag & drop ── */
.drag-over {
border-top: 2px solid #c8ff00 !important;
}
.dragging {
opacity: 0.4;
}

View File

@@ -0,0 +1,44 @@
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 './globals.css'
const syne = Syne({
subsets: ['latin', 'latin-ext'],
weight: ['400', '700', '800'],
variable: '--font-syne',
})
const dmSans = DM_Sans({
subsets: ['latin'],
weight: ['300', '400', '500'],
variable: '--font-dm-sans',
})
export const metadata: Metadata = {
title: 'Party Mix',
description: 'Совместные плейлисты для вечеринок — музыка с hitmotop.com',
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={`${syne.variable} ${dmSans.variable}`}>
<body className="font-sans bg-bg text-app-text min-h-screen pb-[72px] px-4 pt-5 sm:px-4">
<AudioBackground />
<div className="relative" style={{ zIndex: 1 }}>
<AuthHydrator />
{children}
</div>
<GlobalPlayer />
</body>
</html>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { login } from '@/lib/authApi'
import { useAuthStore } from '@/store/authStore'
import Header from '@/components/Header'
function RegisteredNotice() {
const params = useSearchParams()
if (!params.get('registered')) return null
return (
<div className="text-xs text-[#4ade80] bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.15)] px-3 py-2 rounded-lg mb-4">
Аккаунт создан войдите
</div>
)
}
export default function LoginPage() {
const router = useRouter()
const { setAuth } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token, user } = await login(email, password)
setAuth(token, user)
router.push('/')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {
setLoading(false)
}
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="max-w-sm mx-auto mt-8">
<h2 className="font-display text-xl font-extrabold tracking-tight mb-6">Вход</h2>
<Suspense>
<RegisteredNotice />
</Suspense>
<form onSubmit={handleSubmit} className="bg-surface border border-white/[0.07] rounded-app p-5 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
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 placeholder:text-muted transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
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 placeholder:text-muted transition-colors"
/>
</div>
{error && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 font-display text-[13px] font-bold tracking-[0.4px] bg-accent border-none rounded-[9px] text-bg flex items-center justify-center gap-2 hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{loading ? (
<div className="w-4 h-4 rounded-full border-2 border-bg/30 border-t-bg animate-spin" />
) : (
'Войти'
)}
</button>
</form>
<p className="text-center text-xs text-muted mt-4">
Нет аккаунта?{' '}
<Link href="/register" className="text-accent hover:underline">
Зарегистрироваться
</Link>
</p>
</div>
</main>
)
}

164
apps/web/src/app/page.tsx Normal file
View File

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

View File

@@ -0,0 +1,783 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/authStore'
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 type { Playlist, SearchResult } from '@/types'
import Header from '@/components/Header'
const TAG_PALETTE = ['#c8ff00', '#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 TrackVersionPicker({ title, onPlay }: { title: string; onPlay: (r: SearchResult) => void }) {
const { isSaved, saveVersion, removeVersion } = useVersionStore()
const [results, setResults] = useState<SearchResult[] | null>(null)
useEffect(() => {
searchTracks(title).then(setResults).catch(() => setResults([]))
}, [title])
if (results === null) {
return (
<div className="flex items-center gap-2 px-4 py-2.5 text-muted text-[12px] border-t border-white/[0.05]">
<div className="w-3 h-3 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
Ищем версии...
</div>
)
}
if (!results.length) {
return <div className="px-4 py-2.5 text-[12px] text-muted border-t border-white/[0.05]">Версии не найдены</div>
}
return (
<div className="border-t border-white/[0.05]">
{results.map((r, i) => {
const saved = isSaved(title, r)
const hasImg = r.img && !r.img.includes('no-cover')
return (
<div key={i} className="flex items-center gap-2.5 px-4 py-2 hover:bg-surface2 transition-colors border-b border-white/[0.04] last:border-b-0">
<span className="text-[10px] text-muted/40 font-mono w-4 text-right shrink-0">{i + 1}</span>
{hasImg && (
<img src={proxyImgUrl(r.img)} alt="" className="w-7 h-7 rounded-md object-cover shrink-0 bg-surface2"
onError={e => ((e.target as HTMLImageElement).style.display = 'none')} />
)}
<div className="flex-1 min-w-0">
<div className="text-[12px] text-app-text truncate leading-tight">{r.title}</div>
<div className="text-[10px] text-muted">{r.artist}</div>
</div>
<span className="text-[10px] text-muted/60 font-display shrink-0 hidden sm:block">{r.duration}</span>
<button
onClick={() => saved ? removeVersion(title) : saveVersion(title, r)}
title={saved ? 'Забыть версию' : 'Запомнить эту версию'}
className="w-6 h-6 rounded-md border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(200,255,0,0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={() => onPlay(r)}
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}
>
<svg width="8" height="8" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)
}
function Toggle({ value, onChange, label }: { value: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<label className="flex items-center gap-2.5 cursor-pointer select-none group" onClick={() => onChange(!value)}>
<div
className="relative w-9 h-5 rounded-full transition-colors duration-200 shrink-0"
style={{
background: value ? 'rgba(200,255,0,0.25)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${value ? 'rgba(200,255,0,0.45)' : 'rgba(255,255,255,0.1)'}`,
}}
>
<div
className="absolute top-[2px] w-4 h-4 rounded-full transition-all duration-200"
style={{ left: value ? 'calc(100% - 18px)' : '2px', background: value ? '#c8ff00' : 'rgba(255,255,255,0.25)' }}
/>
</div>
<span className="text-[12px] text-muted group-hover:text-app-text transition-colors">{label}</span>
</label>
)
}
function TagInput({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const addTag = (val: string) => {
const tag = val.trim().replace(/,$/, '').trim()
if (tag && !tags.includes(tag) && tags.length < 8) {
onChange([...tags, tag])
}
setInput('')
}
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTag(input)
} else if (e.key === 'Backspace' && !input && tags.length) {
onChange(tags.slice(0, -1))
}
}
return (
<div
className="flex flex-wrap gap-1.5 w-full bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2 cursor-text focus-within:border-accent/35 transition-colors min-h-[42px]"
onClick={() => inputRef.current?.focus()}
>
{tags.map(tag => (
<span
key={tag}
className="flex items-center gap-1 text-[11px] font-display font-bold px-2 py-px rounded-md leading-5"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
>
{tag}
<button
type="button"
onClick={e => { e.stopPropagation(); onChange(tags.filter(t => t !== tag)) }}
className="opacity-60 hover:opacity-100 transition-opacity cursor-pointer leading-none"
>
×
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
onBlur={() => input.trim() && addTag(input)}
placeholder={tags.length ? '' : 'rock, party, 90s… Enter для добавления'}
className="flex-1 min-w-[120px] bg-transparent text-[12px] text-app-text outline-none placeholder:text-muted"
/>
</div>
)
}
function PlaylistCard({
pl,
onEdit,
onDelete,
}: {
pl: Playlist
onEdit: () => void
onDelete: () => void
}) {
const { loadPlaylist } = usePartyStore()
const [expanded, setExpanded] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [versionsFor, setVersionsFor] = useState<string | null>(null)
const [launched, setLaunched] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
const playTrack = (title: string, result?: SearchResult) => {
if (result) {
useVersionStore.getState().saveVersion(title, result)
}
loadPlaylist([title])
}
const playAll = () => {
const titles = pl.tracks?.map(t => t.title) ?? []
if (!titles.length) return
loadPlaylist(titles)
setLaunched(true)
setTimeout(() => setLaunched(false), 2500)
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.11] transition-all duration-200">
<div className="flex items-center gap-3 px-4 py-3.5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-display text-[14px] font-bold text-app-text">{pl.name}</span>
{pl.is_public && (
<span className="text-[9px] font-display font-bold tracking-[1px] uppercase px-1.5 py-px rounded-md" style={{ background: 'rgba(200,255,0,0.1)', color: '#c8ff00' }}>
публичный
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
{tags.map(tag => (
<span key={tag} className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}>
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{trackCount > 0 && (
<button onClick={() => setExpanded(v => !v)}
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all cursor-pointer">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
{trackCount > 0 && (
<button onClick={playAll}
className="text-[11px] font-display font-bold px-2.5 py-1.5 rounded-[9px] cursor-pointer transition-all"
style={{ background: launched ? '#c8ff00' : 'rgba(200,255,0,0.12)', color: launched ? '#0a0a0f' : '#c8ff00' }}>
Play
</button>
)}
<button onClick={onEdit}
className="text-[11px] px-2.5 py-1.5 border border-white/[0.07] rounded-lg bg-transparent text-muted hover:text-app-text hover:border-white/20 transition-all cursor-pointer font-display font-semibold">
Изменить
</button>
{confirmDelete ? (
<div className="flex items-center gap-1">
<button onClick={onDelete}
className="text-[11px] px-2.5 py-1.5 border border-[rgba(255,100,100,0.35)] rounded-lg bg-[rgba(255,100,100,0.1)] text-[#ff6b6b] cursor-pointer font-display font-semibold">
Да, удалить
</button>
<button onClick={() => setConfirmDelete(false)}
className="text-[11px] px-2 py-1.5 text-muted hover:text-app-text cursor-pointer">
Отмена
</button>
</div>
) : (
<button onClick={() => setConfirmDelete(true)}
className="text-[11px] px-2.5 py-1.5 border border-[rgba(255,100,100,0.15)] rounded-lg bg-transparent text-[rgba(255,100,100,0.45)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.3)] transition-all cursor-pointer font-display font-semibold">
Удалить
</button>
)}
</div>
</div>
{expanded && pl.tracks && pl.tracks.length > 0 && (
<div className="border-t border-white/[0.05]">
{pl.tracks.map((track, i) => (
<div key={track.id}>
<div className="flex items-center gap-2.5 px-4 py-2 group hover:bg-surface2 transition-colors">
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
<span className="text-[12px] text-app-text/80 group-hover:text-app-text truncate flex-1 transition-colors">{track.title}</span>
<button
onClick={() => setVersionsFor(versionsFor === track.title ? null : track.title)}
title="Версии трека"
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: versionsFor === track.title ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.1)', background: versionsFor === track.title ? 'rgba(200,255,0,0.08)' : 'transparent' }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={versionsFor === track.title ? '#c8ff00' : '#777'} strokeWidth="2">
<path d="M9 18V5l12-2v13" strokeLinecap="round" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</button>
<button
onClick={() => playTrack(track.title)}
title="Воспроизвести"
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}>
<svg width="8" height="8" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
{versionsFor === track.title && (
<TrackVersionPicker title={track.title} onPlay={r => playTrack(track.title, r)} />
)}
</div>
))}
</div>
)}
</div>
)
}
function EditForm({
pl,
onSave,
onCancel,
}: {
pl: Playlist
onSave: (name: string, tracks: string[], isPublic: boolean, tags: string[]) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState(pl.name)
const [tracks, setTracks] = useState(pl.tracks?.map(t => t.title).join('\n') ?? '')
const [isPublic, setIsPublic] = useState(pl.is_public)
const [tags, setTags] = useState<string[]>(pl.tags ?? [])
const [saving, setSaving] = useState(false)
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleSave = async () => {
setSaving(true)
await onSave(name.trim(), parseTracks(tracks), isPublic, tags)
setSaving(false)
}
return (
<div className="p-4 bg-surface2/50 border-t border-white/[0.05]">
<input
type="text"
value={name}
onChange={e => setName(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-2 transition-colors"
placeholder="Название плейлиста"
/>
<textarea
value={tracks}
onChange={e => setTracks(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 min-h-[80px] resize-y leading-relaxed mb-2 transition-colors"
placeholder="Треки (по одному на строку)"
/>
<div className="mb-2">
<TagInput tags={tags} onChange={setTags} />
</div>
<div className="mb-3">
<Toggle value={isPublic} onChange={setIsPublic} label="Публичный — виден всем в Сообществе" />
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="flex-1 py-2 font-display text-[12px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 cursor-pointer disabled:opacity-50 transition-opacity"
>
{saving ? 'Сохраняем...' : 'Сохранить'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 font-display text-[12px] font-bold bg-transparent border border-white/[0.07] rounded-[9px] text-muted hover:text-app-text hover:border-white/20 cursor-pointer transition-all"
>
Отмена
</button>
</div>
</div>
)
}
function FavoritesCard() {
const { favorites, removeFavorite } = useFavoritesStore()
const { loadPlaylist } = usePartyStore()
const [expanded, setExpanded] = useState(false)
const [launched, setLaunched] = useState(false)
const [versionsFor, setVersionsFor] = useState<string | null>(null)
const playTrack = (title: string, result?: SearchResult) => {
if (result) useVersionStore.getState().saveVersion(title, result)
loadPlaylist([title])
}
const handlePlay = () => {
if (!favorites.length) return
loadPlaylist(favorites)
setLaunched(true)
setTimeout(() => setLaunched(false), 2500)
}
return (
<div className="bg-surface border rounded-app overflow-hidden mb-2.5" style={{ borderColor: 'rgba(200,255,0,0.2)' }}>
<div className="flex items-center gap-3 px-4 py-3.5">
<div className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center" style={{ background: 'rgba(200,255,0,0.1)' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="#c8ff00">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-display text-[14px] font-bold text-app-text">Избранное</span>
<span className="text-[9px] font-display font-bold tracking-[1px] uppercase px-1.5 py-px rounded-md" style={{ background: 'rgba(200,255,0,0.1)', color: '#c8ff00' }}>
локальный
</span>
</div>
<div className="text-[11px] text-muted mt-0.5">
{favorites.length} {favorites.length === 1 ? 'трек' : favorites.length < 5 ? 'трека' : 'треков'}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{favorites.length > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all cursor-pointer"
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
onClick={handlePlay}
disabled={!favorites.length}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: launched ? '#c8ff00' : 'rgba(200,255,0,0.12)', color: launched ? '#0a0a0f' : '#c8ff00' }}
>
{launched ? '▶ Играет' : '▶ Play'}
</button>
</div>
</div>
{expanded && favorites.length > 0 && (
<div className="border-t border-white/[0.05]">
{favorites.map((title, i) => (
<div key={i}>
<div className="flex items-center gap-2.5 px-4 py-2 group hover:bg-surface2 transition-colors">
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
<span className="text-[12px] text-app-text/80 group-hover:text-app-text truncate flex-1 transition-colors">{title}</span>
<button
onClick={() => setVersionsFor(versionsFor === title ? null : title)}
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: versionsFor === title ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.1)', background: versionsFor === title ? 'rgba(200,255,0,0.08)' : 'transparent' }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={versionsFor === title ? '#c8ff00' : '#777'} strokeWidth="2">
<path d="M9 18V5l12-2v13" strokeLinecap="round" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</button>
<button
onClick={() => playTrack(title)}
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}>
<svg width="8" height="8" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
<button onClick={() => removeFavorite(title)}
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center text-muted hover:text-[#ff6b6b] transition-all cursor-pointer">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
</button>
</div>
{versionsFor === title && (
<TrackVersionPicker title={title} onPlay={r => playTrack(title, r)} />
)}
</div>
))}
</div>
)}
</div>
)
}
function YandexImportForm({ onImport, onClose }: {
onImport: (name: string, tracks: string[]) => Promise<void>
onClose: () => void
}) {
const [importUrl, setImportUrl] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [preview, setPreview] = useState<{ name: string; tracks: string[] } | null>(null)
const [playlistName, setPlaylistName] = useState('')
const [creating, setCreating] = useState(false)
const handleLoad = async () => {
if (!importUrl.trim()) return
setLoading(true)
setError('')
setPreview(null)
try {
const data = await fetchYandexPlaylist(importUrl.trim())
if (!data.tracks.length) {
setError('Плейлист пуст или не удалось получить треки')
return
}
setPreview(data)
setPlaylistName(data.name || 'Плейлист из Яндекс.Музыки')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
if (!preview || !playlistName.trim()) return
setCreating(true)
try {
await onImport(playlistName.trim(), preview.tracks)
} finally {
setCreating(false)
}
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
<div className="flex items-center justify-between mb-3">
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Импорт из Яндекс.Музыки
</p>
<button onClick={onClose} className="text-muted hover:text-app-text transition-colors cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="flex gap-2 mb-3">
<input
type="url"
value={importUrl}
onChange={e => { setImportUrl(e.target.value); setPreview(null); setError('') }}
onKeyDown={e => e.key === 'Enter' && handleLoad()}
placeholder="https://music.yandex.ru/playlists/lk...."
className="flex-1 font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={handleLoad}
disabled={loading || !importUrl.trim()}
className="px-3.5 py-2.5 font-display text-[12px] font-bold rounded-[9px] border border-white/[0.1] text-muted hover:text-app-text hover:border-white/20 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0 flex items-center gap-1.5"
>
{loading ? (
<><div className="w-3 h-3 rounded-full border-2 border-surface2 border-t-accent animate-spin" /> Загрузка</>
) : 'Загрузить'}
</button>
</div>
{error && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg mb-3">
{error}
</div>
)}
{preview && (
<>
<div className="bg-surface2 border border-white/[0.06] rounded-[9px] p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-muted">Найдено треков:</span>
<span className="text-[11px] font-display font-bold" style={{ color: '#c8ff00' }}>{preview.tracks.length}</span>
</div>
<div className="max-h-[140px] overflow-y-auto flex flex-col gap-0.5">
{preview.tracks.map((t, i) => (
<div key={i} className="flex items-center gap-2 py-0.5">
<span className="text-[10px] text-muted/40 font-mono w-4 text-right shrink-0">{i + 1}</span>
<span className="text-[11px] text-app-text/70 truncate">{t}</span>
</div>
))}
</div>
</div>
<input
type="text"
value={playlistName}
onChange={e => setPlaylistName(e.target.value)}
className="w-full font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 mb-3 transition-colors"
placeholder="Название плейлиста"
/>
<button
onClick={handleCreate}
disabled={creating || !playlistName.trim()}
className="w-full py-2.5 font-display text-[13px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{creating ? 'Создаём...' : `Создать плейлист (${preview.tracks.length} треков)`}
</button>
</>
)}
</div>
)
}
export default function PlaylistsPage() {
const router = useRouter()
const { token, user } = useAuthStore()
const { hydrate: hydrateFavorites } = useFavoritesStore()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [newTracks, setNewTracks] = useState('')
const [newIsPublic, setNewIsPublic] = useState(false)
const [newTags, setNewTags] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const [createError, setCreateError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
useEffect(() => {
if (!token) { router.push('/login'); return }
getPlaylists(token)
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false))
}, [token, 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
const tracks = parseTracks(newTracks)
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
setCreateError('')
setSaving(true)
try {
const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags)
setPlaylists(prev => [pl, ...prev])
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
setShowForm(false)
} catch (err: unknown) {
setCreateError(err instanceof Error ? err.message : 'Ошибка')
} finally {
setSaving(false)
}
}
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
if (!token) return
try {
const updated = await updatePlaylist(token, 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
setPlaylists(prev => prev.filter(p => p.id !== id))
await deletePlaylist(token, id).catch(() => {})
}
const handleImport = async (name: string, tracks: string[]) => {
if (!token) return
const pl = await createPlaylist(token, name, tracks, false, [])
setPlaylists(prev => [pl, ...prev])
setShowImport(false)
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight">
Мои плейлисты
{user && <span className="text-muted font-normal text-sm ml-2"> {user.username}</span>}
</h2>
{!loading && (
<p className="text-[12px] text-muted mt-0.5">{playlists.length} плейлистов</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowImport(v => !v); setShowForm(false) }}
className="flex items-center gap-1.5 text-[12px] font-display font-bold px-3.5 py-2 rounded-xl transition-all duration-150 cursor-pointer"
style={showImport
? { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--color-muted)' }
: { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--color-app-text)' }
}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z" /><path d="M8 12h8M12 8l4 4-4 4" />
</svg>
Конвертировать
</button>
<button
onClick={() => { setShowForm(v => !v); setShowImport(false) }}
className="flex items-center gap-1.5 text-[12px] font-display font-bold px-3.5 py-2 rounded-xl transition-all duration-150 cursor-pointer"
style={showForm
? { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--color-muted)' }
: { background: '#c8ff00', color: '#0a0a0f' }
}
>
{showForm ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
Отмена
</>
) : (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
Создать
</>
)}
</button>
</div>
</div>
<FavoritesCard />
{showImport && (
<YandexImportForm
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
)}
{showForm && (
<form onSubmit={handleCreate} className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted mb-3">Новый плейлист</p>
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Название плейлиста"
required
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 placeholder:text-muted transition-colors mb-2"
/>
<textarea
value={newTracks}
onChange={e => setNewTracks(e.target.value)}
placeholder={'Тараканы — Пойдём на улицу\nPALC — Залип\nДора — Втюрилась'}
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 placeholder:text-muted transition-colors min-h-[90px] resize-y leading-relaxed mb-2"
/>
<div className="mb-2.5">
<p className="text-[11px] text-muted mb-1.5">Теги <span className="opacity-50">(необязательно)</span></p>
<TagInput tags={newTags} onChange={setNewTags} />
</div>
<div className="mb-3">
<Toggle
value={newIsPublic}
onChange={setNewIsPublic}
label={`Публичный — виден всем в ${String.fromCharCode(0x22)}Сообществе${String.fromCharCode(0x22)}`}
/>
</div>
{createError && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg mb-2">
{createError}
</div>
)}
<button
type="submit"
disabled={saving}
className="w-full py-2.5 font-display text-[13px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{saving ? 'Сохраняем...' : 'Создать плейлист'}
</button>
</form>
)}
{loading ? (
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
Загрузка...
</div>
) : !playlists.length ? (
<div className="text-center py-14 text-muted">
<div className="text-4xl mb-3 opacity-20">🎵</div>
<p className="text-[13px] font-medium">Нет плейлистов</p>
<p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
{playlists.map(pl => (
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
<PlaylistCard
pl={pl}
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
onDelete={() => handleDelete(pl.id)}
/>
{editingId === pl.id && (
<EditForm
pl={pl}
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
onCancel={() => setEditingId(null)}
/>
)}
</div>
))}
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { register } from '@/lib/authApi'
import Header from '@/components/Header'
export default function RegisterPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await register(username, email, password)
router.push('/login?registered=1')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка регистрации')
} finally {
setLoading(false)
}
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="max-w-sm mx-auto mt-8">
<h2 className="font-display text-xl font-extrabold tracking-tight mb-6">Регистрация</h2>
<form onSubmit={handleSubmit} className="bg-surface border border-white/[0.07] rounded-app p-5 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Имя пользователя</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="cooluser"
required
minLength={3}
maxLength={50}
autoComplete="username"
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 placeholder:text-muted transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
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 placeholder:text-muted transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={6}
autoComplete="new-password"
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 placeholder:text-muted transition-colors"
/>
</div>
{error && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 font-display text-[13px] font-bold tracking-[0.4px] bg-accent border-none rounded-[9px] text-bg flex items-center justify-center gap-2 hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{loading ? (
<div className="w-4 h-4 rounded-full border-2 border-bg/30 border-t-bg animate-spin" />
) : (
'Создать аккаунт'
)}
</button>
</form>
<p className="text-center text-xs text-muted mt-4">
Уже есть аккаунт?{' '}
<Link href="/login" className="text-accent hover:underline">
Войти
</Link>
</p>
</div>
</main>
)
}

View File

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

View File

@@ -0,0 +1,259 @@
'use client'
import { useRef, useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { searchTracks, proxyImgUrl, proxyMp3Url } from '@/lib/api'
import AddToPlaylist from '@/components/AddToPlaylist'
import Header from '@/components/Header'
import type { SearchResult } from '@/types'
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<HTMLButtonElement>(null)
const favorited = isFavorite(result.title)
const saved = isSaved(result.title, result)
const hasImg = result.img && !result.img.includes('no-cover')
return (
<div className="flex items-center gap-3 bg-surface border border-white/[0.07] rounded-app px-3.5 py-2.5 hover:border-white/[0.13] transition-all duration-150 group">
{/* Cover */}
<div className="w-10 h-10 rounded-[8px] bg-surface2 shrink-0 overflow-hidden">
{hasImg ? (
<img
src={proxyImgUrl(result.img)}
alt=""
className="w-full h-full object-cover"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-app-text truncate leading-tight">{result.title}</div>
<div className="text-[11px] text-muted mt-px truncate">{result.artist}</div>
</div>
{/* Duration */}
{result.duration && (
<span className="text-[11px] text-muted font-display shrink-0 hidden sm:block">{result.duration}</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{/* Save version */}
<button
onClick={() => saved ? removeVersion(result.title) : saveVersion(result.title, result)}
title={saved ? 'Забыть версию' : 'Запомнить эту версию'}
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: saved ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
{/* Add to playlist */}
<div className="relative">
<button
ref={addBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
title="Добавить в плейлист"
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
borderColor: playlistOpen ? 'rgba(200,255,0,0.35)' : 'rgba(255,255,255,0.07)',
background: playlistOpen ? 'rgba(200,255,0,0.06)' : 'transparent',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={playlistOpen ? '#c8ff00' : '#666'} strokeWidth="2.5" strokeLinecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{playlistOpen && (
<AddToPlaylist
trackTitle={result.title}
onClose={() => setPlaylistOpen(false)}
anchorRef={addBtnRef as React.RefObject<HTMLElement | null>}
/>
)}
</div>
{/* Favorite */}
<button
onClick={() => toggleFavorite(result.title)}
title={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
borderColor: favorited ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: favorited ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
{/* Play */}
<button
onClick={() => onPlay(result)}
title="Воспроизвести"
className="w-7 h-7 rounded-[7px] flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
</div>
)
}
export default function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [loading, setLoading] = useState(false)
const [lastQuery, setLastQuery] = useState('')
const { loadPlaylist } = usePartyStore()
const inputRef = useRef<HTMLInputElement>(null)
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault()
const q = query.trim()
if (!q) return
setLoading(true)
setResults(null)
setLastQuery(q)
try {
const found = await searchTracks(q)
setResults(found)
} catch {
setResults([])
} finally {
setLoading(false)
}
}
const handlePlay = (r: SearchResult) => {
const title = r.artist ? `${r.artist}${r.title}` : r.title
loadPlaylist([title])
}
const handlePlayAll = () => {
if (!results?.length) return
const titles = results.map(r => r.artist ? `${r.artist}${r.title}` : r.title)
loadPlaylist(titles)
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="mb-5">
<h2 className="font-display text-xl font-extrabold tracking-tight">Поиск</h2>
<p className="text-[12px] text-muted mt-0.5">Исполнитель, трек или «Исполнитель Название»</p>
</div>
<form onSubmit={handleSearch} className="flex gap-2 mb-5">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Кино, Земфира, Нирвана — Smells Like..."
autoFocus
className="w-full font-sans text-[14px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-10 py-3 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{query && (
<button
type="button"
onClick={() => { setQuery(''); inputRef.current?.focus() }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
<button
type="submit"
disabled={!query.trim() || loading}
className="px-5 py-3 bg-accent text-bg font-display font-bold text-sm rounded-[11px] hover:brightness-110 active:scale-[0.97] transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0"
>
Найти
</button>
</form>
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-14 gap-2.5 text-muted">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
<span className="text-[13px]">Ищем «{lastQuery}»...</span>
</div>
)}
{/* Results */}
{results !== null && !loading && (
<>
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
{results.length ? `${results.length} результатов — «${lastQuery}»` : `Ничего не найдено — «${lastQuery}»`}
</p>
{results.length > 1 && (
<button
onClick={handlePlayAll}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-3 py-1.5 rounded-[9px] cursor-pointer transition-all"
style={{ background: 'rgba(200,255,0,0.12)', color: '#c8ff00' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
Все в очередь
</button>
)}
</div>
{!results.length ? (
<div className="text-center py-12 text-muted">
<div className="text-4xl mb-3 opacity-20">🔍</div>
<p className="text-[13px]">Попробуйте другой запрос</p>
</div>
) : (
<div className="flex flex-col gap-2">
{results.map((r, i) => (
<ResultCard key={i} result={r} onPlay={handlePlay} />
))}
</div>
)}
</>
)}
{/* Empty state */}
{results === null && !loading && (
<div className="text-center py-14 text-muted">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 opacity-20">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" strokeLinecap="round" />
</svg>
<p className="text-[13px] font-medium mb-1">Начните поиск</p>
<p className="text-[12px] opacity-50">Введите исполнителя или название трека</p>
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useAuthStore } from '@/store/authStore'
import { getPlaylists, addTrackToPlaylist } from '@/lib/authApi'
import type { Playlist } from '@/types'
interface Props {
trackTitle: string
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
}
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { token } = useAuthStore()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [added, setAdded] = useState<Record<string, boolean>>({})
const ref = useRef<HTMLDivElement>(null)
const favorited = isFavorite(trackTitle)
useEffect(() => {
if (!token) return
getPlaylists(token).then(setPlaylists).catch(() => {})
}, [token])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
ref.current && !ref.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) onClose()
}
setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => document.removeEventListener('mousedown', handler)
}, [onClose, anchorRef])
const handleAdd = async (playlist: Playlist) => {
if (!token || added[playlist.id]) return
try {
await addTrackToPlaylist(token, playlist.id, trackTitle)
setAdded(prev => ({ ...prev, [playlist.id]: true }))
} catch {}
}
return (
<div ref={ref} className="absolute z-50 right-0 mt-1 w-56 bg-[#18181f] border border-white/[0.1] rounded-[13px] shadow-[0_8px_32px_rgba(0,0,0,0.5)] overflow-hidden">
<div className="px-3 py-2.5 flex items-center justify-between border-b border-white/[0.06]">
<span className="text-[11px] font-display font-bold text-muted uppercase tracking-[0.5px]">Плейлисты</span>
<button onClick={onClose} className="text-muted hover:text-app-text transition-colors cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
</button>
</div>
<button
onClick={() => toggleFavorite(trackTitle)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-surface2 transition-colors cursor-pointer border-b border-white/[0.05]"
>
<div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style={{ background: favorited ? 'rgba(200,255,0,0.15)' : 'rgba(255,255,255,0.06)' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '#666'} strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<span className="text-[12px] font-medium" style={{ color: favorited ? '#c8ff00' : 'var(--color-app-text)' }}>
{favorited ? 'В избранном' : 'В избранное'}
</span>
{favorited && (
<svg className="ml-auto shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
{token ? (
playlists.length > 0 ? (
<div className="max-h-[180px] overflow-y-auto">
{playlists.map(pl => (
<button
key={pl.id}
onClick={() => handleAdd(pl)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-surface2 transition-colors cursor-pointer border-b border-white/[0.04] last:border-b-0"
>
<div className="w-6 h-6 rounded-md bg-surface2 flex items-center justify-center shrink-0">
<svg className="shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<path d="M9 18V5l12-2v13" strokeLinecap="round" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<span className="text-[12px] font-medium text-app-text truncate flex-1 text-left">{pl.name}</span>
{added[pl.id] ? (
<svg className="ml-auto shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="ml-auto shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
)}
</button>
))}
</div>
) : (
<div className="px-3 py-3 text-[11px] text-muted text-center">Плейлистов нет</div>
)
) : (
<div className="px-3 py-3 text-[11px] text-muted text-center">Войдите чтобы добавить в плейлист</div>
)}
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import { useEffect, useRef } from 'react'
import { audioState } from '@/lib/audioState'
export default function AudioBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let rafId: number
let smoothBass = 0
let smoothMid = 0
let dataBuf: Uint8Array<ArrayBuffer> | null = null
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
const draw = () => {
rafId = requestAnimationFrame(draw)
const W = canvas.width
const H = canvas.height
let rawBass = 0
let rawMid = 0
if (audioState.analyser && audioState.isPlaying) {
const binCount = audioState.analyser.frequencyBinCount
if (!dataBuf || dataBuf.length !== binCount) dataBuf = new Uint8Array(new ArrayBuffer(binCount))
audioState.analyser.getByteFrequencyData(dataBuf)
const bassEnd = Math.max(1, Math.ceil(binCount * 0.1))
for (let i = 0; i < bassEnd; i++) rawBass = Math.max(rawBass, dataBuf[i] / 255)
const midStart = bassEnd
const midEnd = Math.ceil(binCount * 0.4)
let midSum = 0
for (let i = midStart; i < midEnd; i++) midSum += dataBuf[i] / 255
rawMid = midSum / Math.max(1, midEnd - midStart)
}
smoothBass += (rawBass - smoothBass) * 0.1
smoothMid += (rawMid - smoothMid) * 0.07
const t = Date.now() / 4500
const breathe = Math.sin(t) * 0.03 + Math.cos(t * 0.7) * 0.015
ctx.clearRect(0, 0, W, H)
ctx.fillStyle = '#0a0a0f'
ctx.fillRect(0, 0, W, H)
const diag = Math.hypot(W, H)
const base = diag * 0.55
// Lime orb — bass driven, top-left
const r1 = base * (0.75 + smoothBass * 0.55 + breathe)
const a1 = 0.055 + smoothBass * 0.07
const g1 = ctx.createRadialGradient(W * 0.18, H * 0.08, 0, W * 0.18, H * 0.08, r1)
g1.addColorStop(0, `rgba(200,255,0,${a1})`)
g1.addColorStop(0.45, `rgba(200,255,0,${a1 * 0.28})`)
g1.addColorStop(1, 'rgba(200,255,0,0)')
ctx.fillStyle = g1
ctx.fillRect(0, 0, W, H)
// Pink orb — mid driven, bottom-right
const r2 = base * (0.65 + smoothMid * 0.45 - breathe * 0.6)
const a2 = 0.045 + smoothMid * 0.055
const g2 = ctx.createRadialGradient(W * 0.82, H * 0.92, 0, W * 0.82, H * 0.92, r2)
g2.addColorStop(0, `rgba(255,60,172,${a2})`)
g2.addColorStop(0.45, `rgba(255,60,172,${a2 * 0.28})`)
g2.addColorStop(1, 'rgba(255,60,172,0)')
ctx.fillStyle = g2
ctx.fillRect(0, 0, W, H)
// Purple orb — combined, center, fades in when playing
const combined = smoothBass * 0.55 + smoothMid * 0.45
if (combined > 0.005) {
const r3 = base * (0.38 + combined * 0.32)
const a3 = combined * 0.035
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.52, 0, W * 0.5, H * 0.52, r3)
g3.addColorStop(0, `rgba(140,100,255,${a3})`)
g3.addColorStop(1, 'rgba(140,100,255,0)')
ctx.fillStyle = g3
ctx.fillRect(0, 0, W, H)
}
}
draw()
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', resize)
}
}, [])
return (
<canvas
ref={canvasRef}
className="fixed inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 0 }}
/>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { useEffect } from 'react'
import { useAuthStore } from '@/store/authStore'
export default function AuthHydrator() {
useEffect(() => {
useAuthStore.getState().hydrate()
}, [])
return null
}

View File

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

View File

@@ -0,0 +1,183 @@
'use client'
import { useState, useMemo } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { COLORS, initials } from '@/lib/colors'
import type { Color } from '@/types'
export default function ExtraTab() {
const { queue, curIdx, addFairToQueue } = usePartyStore()
const [name, setName] = useState('')
const [tracks, setTracks] = useState('')
const [selectedColor, setSelectedColor] = useState<Color>(COLORS[1])
const [done, setDone] = useState(false)
// Unique participants in the remaining queue
const participants = useMemo(() => {
const seen = new Map<string, Color>()
for (const item of queue.slice(Math.max(curIdx + 1, 0))) {
if (!seen.has(item.owner)) seen.set(item.owner, item.color)
}
return Array.from(seen.entries()).map(([owner, color]) => ({ owner, color }))
}, [queue, curIdx])
// Also include played participants (for same-name additions)
const allParticipants = useMemo(() => {
const seen = new Map<string, Color>()
for (const item of queue) {
if (!seen.has(item.owner)) seen.set(item.owner, item.color)
}
return Array.from(seen.entries()).map(([owner, color]) => ({ owner, color }))
}, [queue])
const selectParticipant = (owner: string, color: Color) => {
setName(owner)
setSelectedColor(color)
}
const parseTracks = (raw: string) =>
raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const n = name.trim()
const t = parseTracks(tracks)
if (!n || !t.length) return
// If existing participant, reuse their color
const existing = allParticipants.find(p => p.owner === n)
const color = existing ? existing.color : selectedColor
addFairToQueue(n, color, t)
setTracks('')
setDone(true)
setTimeout(() => setDone(false), 2000)
}
const remaining = queue.length - Math.max(curIdx + 1, 0)
return (
<div className="animate-fadeUp">
{/* Info about current queue */}
{queue.length > 0 && (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Сейчас в очереди
</p>
{participants.length === 0 ? (
<p className="text-[12px] text-muted">Все треки уже сыграны</p>
) : (
<div className="flex flex-col gap-1.5">
{participants.map(({ owner, color }) => {
const count = queue.slice(Math.max(curIdx + 1, 0)).filter(i => i.owner === owner).length
return (
<div key={owner} className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-[11px] font-display font-extrabold shrink-0"
style={{ background: color.bg, color: color.text }}
>
{initials(owner)}
</div>
<span className="text-[13px] text-app-text flex-1">{owner}</span>
<span className="text-[11px] text-muted">{count} треков</span>
</div>
)
})}
</div>
)}
<div className="mt-3 pt-3 border-t border-white/[0.05] text-[11px] text-muted">
Осталось {remaining} {remaining === 1 ? 'трек' : remaining < 5 ? 'трека' : 'треков'} · всего {queue.length}
</div>
</div>
)}
{/* Add tracks form */}
<form onSubmit={handleSubmit} className="bg-surface border border-white/[0.07] rounded-app p-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Добавить треки в очередь
</p>
{/* Quick-pick existing participants */}
{allParticipants.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{allParticipants.map(({ owner, color }) => (
<button
key={owner}
type="button"
onClick={() => selectParticipant(owner, color)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer"
style={name === owner
? { background: color.bg, color: color.text, border: `1px solid ${color.text}40` }
: { background: 'rgba(255,255,255,0.05)', color: 'var(--color-muted)', border: '1px solid rgba(255,255,255,0.07)' }
}
>
<div
className="w-4 h-4 rounded flex items-center justify-center text-[9px] font-extrabold shrink-0"
style={{ background: color.bg, color: color.text }}
>
{initials(owner)}
</div>
{owner}
</button>
))}
</div>
)}
{/* Name input */}
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Имя участника"
required
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 placeholder:text-muted transition-colors mb-2"
/>
{/* Color picker — only for new participants */}
{!allParticipants.find(p => p.owner === name.trim()) && name.trim() && (
<div className="flex items-center gap-2 mb-2">
<span className="text-[11px] text-muted">Цвет:</span>
{COLORS.map((c, i) => (
<button
key={i}
type="button"
onClick={() => setSelectedColor(c)}
className="w-5 h-5 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: c.text,
outline: selectedColor === c ? `2px solid ${c.text}` : 'none',
outlineOffset: '2px',
opacity: selectedColor === c ? 1 : 0.45,
}}
/>
))}
</div>
)}
{/* Tracks textarea */}
<textarea
value={tracks}
onChange={e => setTracks(e.target.value)}
placeholder={'Тараканы — Пойдём на улицу\nPALC — Залип\nДора — Втюрилась'}
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 placeholder:text-muted transition-colors min-h-[100px] resize-y leading-relaxed mb-3"
/>
{/* Preview */}
{parseTracks(tracks).length > 0 && name.trim() && (
<div className="mb-3 px-3 py-2 bg-surface2 rounded-[9px] text-[11px] text-muted">
{parseTracks(tracks).length} треков будут равномерно добавлены в очередь для «{name.trim()}»
</div>
)}
<button
type="submit"
disabled={!name.trim() || parseTracks(tracks).length === 0}
className="w-full py-2.5 font-display text-[13px] font-bold border-none rounded-[9px] transition-all duration-200 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: done ? 'rgba(200,255,0,0.2)' : '#c8ff00', color: done ? '#c8ff00' : '#0a0a0f' }}
>
{done ? '✓ Добавлено в очередь' : '↗ Добавить в очередь'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { useFavoritesStore } from '@/store/favoritesStore'
import { usePartyStore } from '@/store/partyStore'
export default function FavoritesTab() {
const { favorites, removeFavorite, clearFavorites } = useFavoritesStore()
const { loadPlaylist } = usePartyStore()
if (!favorites.length) {
return (
<div className="animate-fadeUp text-center py-14 text-muted">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 opacity-20">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
<p className="text-sm">Нажмите в плеере, чтобы добавить трек</p>
</div>
)
}
return (
<div className="animate-fadeUp">
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
Избранное · {favorites.length}
</p>
<button
onClick={clearFavorites}
className="text-[11px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить всё
</button>
</div>
<div className="flex flex-col gap-2">
{favorites.map((title, i) => (
<div
key={i}
className="bg-surface border border-white/[0.07] rounded-app px-4 py-3 flex items-center gap-3 hover:bg-surface2 hover:border-white/[0.13] transition-all duration-150"
>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{title}
</div>
</div>
<button
onClick={() => removeFavorite(title)}
title="Убрать из избранного"
className="w-7 h-7 rounded-full border border-white/[0.07] flex items-center justify-center shrink-0 hover:border-[rgba(255,107,107,0.4)] transition-all cursor-pointer text-muted hover:text-[#ff6b6b]"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<button
onClick={() => {
loadPlaylist([title])
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
title="Запустить"
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{
background: 'rgba(200,255,0,0.12)',
borderColor: 'rgba(200,255,0,0.2)',
}}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { useCallback, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { useAuthStore } from '@/store/authStore'
import BottomPlayer from '@/components/BottomPlayer'
import type { SearchResult } from '@/types'
export default function GlobalPlayer() {
const { hydrate: hydrateFavorites } = useFavoritesStore()
const { hydrate: hydrateVersions } = useVersionStore()
const { token } = useAuthStore()
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
// Re-hydrate versions whenever auth changes (null → token = fetch from API)
useEffect(() => {
hydrateVersions()
}, [token, hydrateVersions])
const handleTrackEnd = useCallback((result: SearchResult) => {
const { queue, curIdx, addToHistory } = usePartyStore.getState()
const track = queue[curIdx]
if (!track) return
addToHistory({
title: result.title,
artist: result.artist,
img: result.img,
owner: track.owner,
color: track.color,
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
})
}, [])
return <BottomPlayer onTrackEnd={handleTrackEnd} />
}

View File

@@ -0,0 +1,111 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/authStore'
export default function Header() {
const { user, clearAuth } = useAuthStore()
const router = useRouter()
const pathname = usePathname()
const handleLogout = () => {
clearAuth()
router.push('/')
}
const navLink = (href: string, label: string, icon: React.ReactNode) => {
const active = pathname === href
return (
<Link
href={href}
className="flex items-center gap-1.5 text-[12px] font-display font-semibold px-3 py-1.5 rounded-xl border transition-all duration-150 hidden sm:flex"
style={active
? { background: 'rgba(200,255,0,0.12)', borderColor: 'rgba(200,255,0,0.3)', color: '#c8ff00' }
: { background: 'rgba(255,255,255,0.04)', borderColor: 'rgba(255,255,255,0.07)', color: 'var(--color-muted)' }
}
>
{icon}
{label}
</Link>
)
}
return (
<div className="flex items-center gap-2.5 mb-5 pb-5 border-b border-white/[0.07]">
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline">
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
</Link>
<div className="flex items-center gap-2 shrink-0">
{navLink('/search', 'Поиск',
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
{navLink('/community', 'Сообщество',
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
{user ? (
<>
{navLink('/playlists', 'Плейлисты',
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<div className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5">
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0">
<span className="text-[10px] font-display font-extrabold text-accent">
{user.username[0].toUpperCase()}
</span>
</div>
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
<button
onClick={handleLogout}
className="flex items-center gap-1 text-[11px] font-display font-semibold text-muted hover:text-[#ff6b6b] transition-colors duration-150 cursor-pointer ml-1 border-l border-white/[0.07] pl-2"
title="Выйти из аккаунта"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="hidden sm:inline">Выйти</span>
</button>
</div>
</>
) : (
<>
<Link
href="/login"
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5"
>
Войти
</Link>
<Link
href="/register"
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
>
Регистрация
</Link>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { usePartyStore } from '@/store/partyStore'
import { proxyImgUrl } from '@/lib/api'
export default function HistoryTab() {
const { history, clearHistory } = usePartyStore()
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.07] sm:px-3">
<span className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Уже сыграло
</span>
{history.length > 0 && (
<button
onClick={clearHistory}
className="text-[11px] px-2.5 py-0.5 border border-[rgba(255,100,100,0.2)] rounded-[7px] bg-transparent text-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 cursor-pointer font-sans"
>
Очистить
</button>
)}
</div>
{!history.length ? (
<div className="text-center py-12 px-4 text-muted text-[13px]">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mb-3 opacity-30 mx-auto">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div>История пуста</div>
<div className="text-[11px] mt-1 opacity-60">Здесь появятся треки которые уже сыграли</div>
</div>
) : (
history.map((item, i) => (
<div
key={i}
className="flex items-center gap-2.5 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 animate-fadeUp sm:px-3"
style={{ animationDelay: `${i * 0.03}s` }}
>
{item.img && !item.img.includes('no-cover') ? (
<img
src={proxyImgUrl(item.img)}
alt=""
className="w-10 h-10 rounded-lg object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-10 h-10 rounded-lg bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{item.title}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className="text-[10px] px-1.5 py-px rounded"
style={{ background: item.color.bg, color: item.color.text }}
>
{item.owner}
</span>
<span className="text-[10px] text-muted">{item.playedAt}</span>
</div>
</div>
</div>
))
)}
</div>
)
}

View File

@@ -0,0 +1,117 @@
'use client'
import { useState, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useAuthStore } from '@/store/authStore'
import { getPlaylists, getPlaylist } from '@/lib/authApi'
import type { Playlist } from '@/types'
export default function AddPersonForm() {
const { addPerson } = usePartyStore()
const { token } = useAuthStore()
const [name, setName] = useState('')
const [tracks, setTracks] = useState('')
const [error, setError] = useState('')
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loadingPlaylist, setLoadingPlaylist] = useState(false)
useEffect(() => {
if (!token) return
getPlaylists(token)
.then(setPlaylists)
.catch(() => {})
}, [token])
const handleSelectPlaylist = async (id: string) => {
if (!token || !id) return
setLoadingPlaylist(true)
try {
const pl = await getPlaylist(token, id)
if (pl.tracks?.length) {
setTracks(pl.tracks.map((t) => t.title).join('\n'))
}
if (!name.trim()) setName(pl.name)
} catch {}
setLoadingPlaylist(false)
}
const handleAdd = () => {
setError('')
if (!name.trim()) return
if (!tracks.trim()) {
setError('Вставьте список треков')
return
}
const parsed = tracks
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 1)
.slice(0, 50)
if (!parsed.length) {
setError('Не нашли треков')
return
}
addPerson(name.trim(), parsed)
setName('')
setTracks('')
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-4 sm:p-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя участника"
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 placeholder:text-muted transition-colors"
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
{token && playlists.length > 0 && (
<div className="mt-2">
<select
onChange={(e) => handleSelectPlaylist(e.target.value)}
defaultValue=""
className="w-full font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-muted outline-none focus:border-accent/35 transition-colors cursor-pointer"
>
<option value="" disabled>
{loadingPlaylist ? 'Загружаем...' : '🎵 Выбрать из моих плейлистов'}
</option>
{playlists.map((pl) => (
<option key={pl.id} value={pl.id}>
{pl.name}
</option>
))}
</select>
</div>
)}
<p className="text-xs text-muted mt-2 mb-1.5 leading-relaxed">
Список треков каждый с новой строки. Формат:{' '}
<b className="text-app-text font-medium">Исполнитель Название</b>
</p>
<textarea
value={tracks}
onChange={(e) => setTracks(e.target.value)}
placeholder={'Тараканы — Пойдём на улицу\nPALC — Залип\nДора — Втюрилась\n...'}
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 placeholder:text-muted transition-colors min-h-[80px] resize-y leading-relaxed mt-1"
/>
{error && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-1.5 rounded-lg mt-1.5">
{error}
</div>
)}
<button
onClick={handleAdd}
className="w-full mt-2.5 py-3 font-display text-[13px] font-bold tracking-[0.4px] bg-surface2 border border-white/[0.07] rounded-[9px] text-app-text flex items-center justify-center gap-1.5 hover:border-accent/30 hover:text-accent transition-all duration-200 cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12l7-7 7 7" />
</svg>
Добавить участника
</button>
</div>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { usePartyStore } from '@/store/partyStore'
import PersonCard from './PersonCard'
import AddPersonForm from './AddPersonForm'
export default function PartyTab() {
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
return (
<div className="animate-fadeUp">
{people.length >= 2 && (
<div className="flex gap-1.5 mb-4 bg-surface border border-white/[0.07] rounded-xl p-1">
<button
onClick={() => setShuffleMode('fair')}
className={`flex-1 py-1.5 text-xs font-display font-semibold tracking-[0.2px] border-none rounded-[7px] cursor-pointer transition-all duration-200 ${
shuffleMode === 'fair' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
По очереди
</button>
<button
onClick={() => setShuffleMode('random')}
className={`flex-1 py-1.5 text-xs font-display font-semibold tracking-[0.2px] border-none rounded-[7px] cursor-pointer transition-all duration-200 ${
shuffleMode === 'random' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
🎲 Случайно
</button>
</div>
)}
<button
onClick={() => {
if (!people.length) {
alert('Добавьте участников!')
return
}
generateMix()
}}
className="w-full py-3.5 mb-4 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8" />
<line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" />
<line x1="15" y1="15" x2="21" y2="21" />
</svg>
Перемешать и включить
</button>
<div className="flex flex-col gap-2.5 mb-4">
{people.map((person, i) => (
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
))}
</div>
<AddPersonForm />
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { initials } from '@/lib/colors'
import type { Person } from '@/types'
interface PersonCardProps {
person: Person
index: number
}
export default function PersonCard({ person, index }: PersonCardProps) {
const { removePerson, queue, curIdx } = usePartyStore()
const [open, setOpen] = useState(false)
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
<div
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2.5 px-4 py-3 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none sm:px-3"
>
<div
className="w-[30px] h-[30px] rounded-[9px] flex items-center justify-center font-display text-[11px] font-extrabold shrink-0"
style={{ background: person.color.bg, color: person.color.text }}
>
{initials(person.name)}
</div>
<span className="font-display text-[13px] font-bold flex-1 tracking-tight">{person.name}</span>
<span className="text-[11px] text-muted shrink-0">{person.tracks.length} тр.</span>
<span className={`text-muted shrink-0 transition-transform duration-200 text-[11px] ${open ? 'rotate-180' : ''}`}></span>
<button
onClick={(e) => { e.stopPropagation(); removePerson(index) }}
className="w-6 h-6 rounded-[7px] border border-[rgba(255,100,100,0.15)] bg-transparent text-[rgba(255,100,100,0.4)] flex items-center justify-center hover:bg-[rgba(255,100,100,0.1)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 shrink-0 cursor-pointer"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div
className="border-t border-white/[0.07] overflow-hidden transition-all duration-300"
style={{ maxHeight: open ? '500px' : '0' }}
>
<div className="flex flex-wrap gap-1 p-3 sm:p-2.5">
{person.tracks.map((t, ti) => {
const isPlaying =
curIdx >= 0 &&
queue[curIdx] &&
queue[curIdx]._pi === index &&
queue[curIdx]._ti === ti
return (
<span
key={ti}
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-[6px] border max-w-[220px] ${
isPlaying
? 'bg-accent/[0.07] border-accent/25 text-accent font-medium'
: 'border-white/[0.07] bg-surface2 text-muted'
}`}
>
{isPlaying && <div className="w-1 h-1 rounded-full bg-[#4ade80] shrink-0" />}
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-w-[165px]">{t.title}</span>
</span>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,352 @@
'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { proxyImgUrl, proxyMp3Url, searchTracks } from '@/lib/api'
import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState'
import AddToPlaylist from '@/components/AddToPlaylist'
import type { SearchResult } from '@/types'
interface PlayerCardProps {
onTrackEnd: (result: SearchResult) => void
}
export default function PlayerCard({ onTrackEnd }: PlayerCardProps) {
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus } =
usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isVizActive, setIsVizActive] = useState(false)
const [coverSrc, setCoverSrc] = useState('')
const [audioMeta, setAudioMeta] = useState({ title: '', artist: '' })
const [results, setResults] = useState<SearchResult[]>([])
const [resultsOpen, setResultsOpen] = useState(false)
const [activeResultIdx, setActiveResultIdx] = useState(0)
const [audioVisible, setAudioVisible] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
const playlistBtnRef = useRef<HTMLButtonElement>(null)
const loadingKeyRef = useRef(-1)
const activeResultsRef = useRef<SearchResult[]>([])
const activeResultIdxRef = useRef(0)
const prefetchTitleRef = useRef<string | null>(null)
const prefetchResultsRef = useRef<SearchResult[]>([])
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isVizActive)
const playResult = useCallback(
(resultList: SearchResult[], resIdx: number) => {
const r = resultList[resIdx]
if (!r) return
setActiveResultIdx(resIdx)
activeResultIdxRef.current = resIdx
setAudioMeta({ title: r.title, artist: r.artist })
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.src = proxyMp3Url(r.mp3)
audio.load()
audio.play().catch(() => {})
setAudioVisible(true)
},
[audioRef],
)
const loadTrack = useCallback(
async (idx: number, key: number) => {
if (idx < 0 || idx >= queue.length) return
loadingKeyRef.current = key
setIsVizActive(false)
setCoverSrc('')
setAudioVisible(false)
setResults([])
setResultsOpen(false)
setAudioMeta({ title: '', artist: '' })
activeResultsRef.current = []
const audio = audioRef.current
if (audio) { audio.pause(); audio.src = '' }
const track = queue[idx]
const cached = prefetchTitleRef.current === track.title ? prefetchResultsRef.current : null
prefetchTitleRef.current = null
prefetchResultsRef.current = []
if (!cached) setSearchStatus('searching')
const found = cached ?? await searchTracks(track.title)
if (loadingKeyRef.current !== key) return
activeResultsRef.current = found
setCurrentResults(found)
setResults(found)
if (!found.length) {
setSearchStatus('not-found')
setTimeout(() => {
if (loadingKeyRef.current !== key) return
const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}, 4000)
return
}
setSearchStatus('idle')
// Auto-select saved version if exists
const saved = getSavedVersion(track.title)
let startIdx = 0
if (saved) {
const si = found.findIndex(r => r.title === saved.title && r.artist === saved.artist)
if (si >= 0) startIdx = si
}
if (found[startIdx]?.img && !found[startIdx].img.includes('no-cover')) {
const proxied = proxyImgUrl(found[startIdx].img)
setCoverSrc(proxied)
updateQueueItemImg(idx, proxied)
}
playResult(found, startIdx)
const nextTrack = queue[idx + 1]
if (nextTrack) {
const nextTitle = nextTrack.title
if (prefetchTitleRef.current !== nextTitle) {
prefetchTitleRef.current = null
prefetchResultsRef.current = []
searchTracks(nextTitle).then(r => {
prefetchTitleRef.current = nextTitle
prefetchResultsRef.current = r
}).catch(() => {})
}
}
},
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion],
)
useEffect(() => {
if (queue.length > 0 && curIdx >= 0) {
loadTrack(curIdx, loadKey)
}
}, [loadKey])
const handlePlay = useCallback(() => {
initAudioViz()
resumeContext()
setIsVizActive(true)
audioState.isPlaying = true
audioState.analyser = analyserRef.current
}, [initAudioViz, resumeContext, analyserRef])
const handlePause = useCallback(() => {
setIsVizActive(false)
audioState.isPlaying = false
}, [])
const handleEnded = useCallback(() => {
setIsVizActive(false)
audioState.isPlaying = false
const r = activeResultsRef.current[activeResultIdxRef.current]
if (r) onTrackEnd(r)
const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}, [onTrackEnd])
if (!queue.length) return null
const track = queue[Math.max(0, curIdx)]
const trackTitle = track.title
const favorited = isFavorite(trackTitle)
const prevTrack = () => { const s = usePartyStore.getState(); if (s.curIdx > 0) s.setCurIdx(s.curIdx - 1) }
const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) }
const nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
return (
<div className="bg-surface border border-white/[0.07] rounded-app mb-4 overflow-hidden animate-fadeUp">
{/* Hero: cover + info */}
<div className="flex relative overflow-hidden border-b border-white/[0.07] min-h-[110px] sm:min-h-[96px]">
<div className={`relative shrink-0 w-[110px] h-[110px] overflow-hidden bg-surface2 sm:w-24 sm:h-24 ${isVizActive ? 'cover-playing' : ''}`}>
<div className="cover-glow" style={{ '--glow-color': `${track.color.text}40` } as React.CSSProperties} />
{coverSrc ? (
<img
src={coverSrc}
alt=""
className={`w-full h-full object-cover block relative z-[1] transition-transform duration-500 ${isVizActive ? 'cover-img-playing' : ''}`}
/>
) : (
<div className="w-full h-full bg-surface2" />
)}
<canvas
ref={canvasRef}
className={`absolute inset-0 w-full h-full pointer-events-none z-[2] transition-opacity duration-300 ${isVizActive ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
<div className="flex-1 px-4 py-3.5 flex flex-col justify-center gap-1 bg-gradient-to-br from-surface2 to-surface min-w-0 sm:px-3">
<div className="text-[10px] font-medium tracking-[1.5px] uppercase text-accent flex items-center gap-1.5">
<div className="flex items-end gap-[2px] h-[10px]">
<div className="eq-bar" /><div className="eq-bar" /><div className="eq-bar" /><div className="eq-bar" />
</div>
Сейчас играет
</div>
<div className="font-display text-[15px] font-extrabold tracking-tight leading-tight whitespace-nowrap overflow-hidden text-ellipsis sm:text-[13px]">
{track.title}
</div>
<span
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-0.5 rounded-[7px] self-start sm:text-[10px]"
style={{ background: track.color.bg, color: track.color.text }}
>
{track.owner}
</span>
</div>
</div>
{/* Search status */}
{searchStatus === 'searching' && (
<div className="px-4 py-3 flex items-center gap-2.5 text-[13px] text-muted border-b border-white/[0.07] sm:px-3">
<div className="w-3.5 h-3.5 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
<span>Ищем...</span>
</div>
)}
{searchStatus === 'not-found' && (
<div className="px-4 py-3 flex items-center gap-2.5 text-[13px] text-muted border-b border-white/[0.07] sm:px-3">
<span>Не найдено пропускаем через 4 секунды...</span>
</div>
)}
{/* Audio player */}
<div style={{ display: audioVisible ? 'block' : 'none' }} className="border-b border-white/[0.07]">
<div className="px-4 py-3 sm:px-3">
<div className="text-xs text-muted mb-1.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span>{audioMeta.title}</span>
<span className="mx-1 opacity-30">·</span>
<span>{audioMeta.artist}</span>
</div>
<audio ref={audioRef} controls preload="auto" crossOrigin="anonymous" onPlay={handlePlay} onPause={handlePause} onEnded={handleEnded} />
</div>
</div>
{/* Version picker */}
{results.length > 1 && (
<div>
<button
onClick={() => setResultsOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-[7px] text-[10px] font-semibold tracking-[1.2px] uppercase text-muted border-b border-white/[0.07] cursor-pointer hover:bg-surface2 transition-colors duration-100 sm:px-3"
>
<span>Версии трека · выберите</span>
<span className={`text-[10px] transition-transform duration-200 ${resultsOpen ? 'rotate-180' : ''}`}></span>
</button>
<div className="overflow-hidden transition-all duration-300" style={{ maxHeight: resultsOpen ? '600px' : '0' }}>
{results.map((r, i) => {
const saved = isSaved(trackTitle, r)
return (
<div
key={i}
onClick={() => playResult(results, i)}
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 sm:px-3 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
>
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
{r.img && !r.img.includes('no-cover') && (
<img src={proxyImgUrl(r.img)} alt="" className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">{r.title}</div>
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
</div>
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
{/* Save version button */}
<button
onClick={(e) => {
e.stopPropagation()
saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r)
}}
title={saved ? 'Забыть версию' : 'Запомнить эту версию'}
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all duration-150 cursor-pointer hover:border-accent/40"
style={{
borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: saved ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
{/* Play button */}
<button
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all duration-150 hover:bg-accent hover:border-accent cursor-pointer ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07] bg-transparent'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
</div>
)}
{/* Controls */}
<div className="flex items-center gap-2 px-4 py-2.5 sm:px-3 sm:gap-1.5">
<button onClick={prevTrack} className="w-9 h-9 rounded-[9px] border border-white/[0.07] bg-transparent flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer shrink-0 sm:w-8 sm:h-8">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
</button>
<button onClick={reloadTrack} className="w-9 h-9 rounded-[9px] border border-white/[0.07] bg-transparent flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer shrink-0 sm:w-8 sm:h-8">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
</button>
<button onClick={nextTrack} className="w-9 h-9 rounded-[9px] border border-white/[0.07] bg-transparent flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer shrink-0 sm:w-8 sm:h-8">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
</button>
<div className="flex-1 text-center text-xs text-muted font-display sm:text-[11px]">{curIdx + 1} / {queue.length}</div>
{/* Add to playlist */}
<div className="relative">
<button
ref={playlistBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
title="Добавить в плейлист"
className="w-9 h-9 rounded-[9px] border flex items-center justify-center shrink-0 transition-all duration-150 cursor-pointer sm:w-8 sm:h-8"
style={{
borderColor: playlistOpen ? 'rgba(200,255,0,0.35)' : 'rgba(255,255,255,0.07)',
background: playlistOpen ? 'rgba(200,255,0,0.06)' : 'transparent',
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={playlistOpen ? '#c8ff00' : '#666'} strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
{playlistOpen && (
<AddToPlaylist
trackTitle={trackTitle}
onClose={() => setPlaylistOpen(false)}
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
/>
)}
</div>
{/* Favorite */}
<button
onClick={() => toggleFavorite(trackTitle)}
title={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-9 h-9 rounded-[9px] border flex items-center justify-center shrink-0 transition-all duration-150 cursor-pointer sm:w-8 sm:h-8"
style={{
borderColor: favorited ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: favorited ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
export default function QueueCard() {
const { queue, curIdx, setCurIdx, generateMix, reorderQueue } = usePartyStore()
const [open, setOpen] = useState(false)
const dragSrcIdx = useRef<number | null>(null)
const handleQueueClick = (e: React.MouseEvent, idx: number) => {
if ((e.target as HTMLElement).closest('.drag-handle')) return
setCurIdx(idx)
}
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
dragSrcIdx.current = idx
el.classList.add('dragging')
}, [])
const onDragEnd = useCallback((el: HTMLElement) => {
el.classList.remove('dragging')
document.querySelectorAll('.q-item').forEach((i) => i.classList.remove('drag-over'))
}, [])
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
e.preventDefault()
document.querySelectorAll('.q-item').forEach((i) => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback(
(e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
reorderQueue(dragSrcIdx.current, tgtIdx)
dragSrcIdx.current = null
},
[reorderQueue],
)
if (!queue.length) return null
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden mb-4">
<div
onClick={() => setOpen((o) => !o)}
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none sm:px-3"
>
<div className="flex items-center gap-2">
<span className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Очередь · {queue.length} треков
</span>
<button
onClick={(e) => { e.stopPropagation(); generateMix() }}
className="px-2.5 py-0.5 text-[11px] border border-white/[0.07] rounded-lg bg-transparent text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer"
>
</button>
</div>
<span className={`text-muted text-[11px] transition-transform duration-200 ${open ? 'rotate-180' : ''}`}></span>
</div>
<div
className="overflow-hidden transition-all duration-300"
style={{ maxHeight: open ? '500px' : '0' }}
>
<div className="flex flex-col max-h-[500px] overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
data-idx={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none relative sm:px-3"
style={{ background: active ? 'rgba(200,255,0,0.04)' : undefined }}
onClick={(e) => handleQueueClick(e, i)}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div
className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 transition-opacity duration-150 flex items-center touch-none"
title="Перетащить"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
<div className="queue-bar" />
<div className="queue-bar" />
<div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img
src={item.img}
alt=""
className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{item.title}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium"
style={{ background: item.color.bg, color: item.color.text }}
>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { fetchTopCharts } from '@/lib/api'
import type { SearchResult } from '@/types'
function TopChartsCard({ tracks }: { tracks: string[] }) {
const { loadPlaylist } = usePartyStore()
const [launched, setLaunched] = useState(false)
const handlePlay = () => {
loadPlaylist(tracks)
setLaunched(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
setTimeout(() => setLaunched(false), 2500)
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" style={{ opacity: 0.8 }} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<span className="text-[10px] font-display font-bold tracking-[1.3px] uppercase text-accent">
Прямо сейчас
</span>
</div>
<h3 className="font-display font-bold text-[15px] text-app-text leading-tight">Топ чарт</h3>
<p className="text-[11px] text-muted mt-0.5">{tracks.length} треков</p>
</div>
<button
onClick={handlePlay}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 mt-1"
style={{
background: launched ? '#c8ff00' : 'rgba(200,255,0,0.15)',
color: launched ? '#0a0a0f' : '#c8ff00',
}}
>
{launched ? '▶ Играет' : '▶ Слушать'}
</button>
</div>
</div>
)
}
export default function SoloTab() {
const [search, setSearch] = useState('')
const [topTracks, setTopTracks] = useState<string[] | null>(null)
const { loadPlaylist } = usePartyStore()
useEffect(() => {
fetchTopCharts().then((results: SearchResult[]) => {
if (!results.length) { setTopTracks([]); return }
setTopTracks(
results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title))
)
})
}, [])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
const q = search.trim()
if (!q) return
loadPlaylist([q])
setSearch('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="animate-fadeUp">
{topTracks === null && (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5 flex items-center gap-2.5 text-muted text-[13px]">
<div className="w-3.5 h-3.5 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
Загружаем чарт...
</div>
)}
{topTracks !== null && topTracks.length > 0 && (
<TopChartsCard tracks={topTracks} />
)}
<div className="bg-surface border border-white/[0.07] rounded-app p-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Быстрый поиск
</p>
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
placeholder="Исполнитель — Название трека"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
type="submit"
disabled={!search.trim()}
className="px-4 py-2.5 bg-accent text-bg font-display font-bold text-sm rounded-[9px] hover:brightness-110 active:scale-[0.97] transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
export type Tab = 'party' | 'extra' | 'history'
interface TabsProps {
active: Tab
historyCount: number
onSwitch: (tab: Tab) => void
}
export default function Tabs({ active, historyCount, onSwitch }: TabsProps) {
return (
<div className="flex gap-0 mb-4 bg-surface border border-white/[0.07] rounded-xl p-[3px]">
<button
onClick={() => onSwitch('party')}
className={`flex-1 py-2 px-1 text-[13px] font-display font-bold tracking-[0.2px] rounded-[9px] flex items-center justify-center gap-1.5 transition-all duration-200 cursor-pointer border-none ${
active === 'party' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Вечеринка</span>
</button>
<button
onClick={() => onSwitch('extra')}
className={`flex-1 py-2 px-1 text-[13px] font-display font-bold tracking-[0.2px] rounded-[9px] flex items-center justify-center gap-1.5 transition-all duration-200 cursor-pointer border-none ${
active === 'extra' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
<span>Экстра</span>
</button>
<button
onClick={() => onSwitch('history')}
className={`flex-1 py-2 px-1 text-[13px] font-display font-bold tracking-[0.2px] rounded-[9px] flex items-center justify-center gap-1.5 transition-all duration-200 cursor-pointer border-none ${
active === 'history' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="hidden sm:inline">История</span>
{historyCount > 0 && (
<span className="bg-accent text-bg text-[10px] font-extrabold px-1.5 py-px rounded min-w-[18px] text-center">
{historyCount}
</span>
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,64 @@
'use client'
import { useEffect, useRef, RefObject } from 'react'
export function useAudioViz(
canvasRef: RefObject<HTMLCanvasElement | null>,
analyserRef: RefObject<AnalyserNode | null>,
isPlaying: boolean,
) {
const rafRef = useRef<number | null>(null)
useEffect(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (!isPlaying || !analyserRef.current || !canvasRef.current) return
const canvas = canvasRef.current
const analyser = analyserRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
const W = canvas.width
const H = canvas.height
const data = new Uint8Array(analyser.frequencyBinCount)
const draw = () => {
rafRef.current = requestAnimationFrame(draw)
analyser.getByteFrequencyData(data)
ctx.clearRect(0, 0, W, H)
const barCount = data.length
const barW = W / barCount
const maxH = H * 0.55
for (let i = 0; i < barCount; i++) {
const v = data[i] / 255
const bh = v * maxH
const x = i * barW
const hue = 80 + i * 3
const g = ctx.createLinearGradient(0, H, 0, H - bh)
g.addColorStop(0, `hsla(${hue},100%,60%,.9)`)
g.addColorStop(1, `hsla(${hue},100%,80%,.1)`)
ctx.fillStyle = g
ctx.fillRect(x, H - bh, barW * 0.7, bh)
}
const ov = ctx.createLinearGradient(0, 0, 0, H)
ov.addColorStop(0, 'rgba(10,10,15,.3)')
ov.addColorStop(0.5, 'rgba(10,10,15,0)')
ov.addColorStop(1, 'rgba(10,10,15,.6)')
ctx.fillStyle = ov
ctx.fillRect(0, 0, W, H)
}
draw()
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [isPlaying, analyserRef, canvasRef])
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useRef, useCallback } from 'react'
export function useAudioEngine() {
const audioRef = useRef<HTMLAudioElement | null>(null)
const audioCtxRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null)
const initAudioViz = useCallback(() => {
const audio = audioRef.current
if (!audio || typeof window === 'undefined') return
try {
if (!audioCtxRef.current) {
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
const ctx = new AudioCtx()
audioCtxRef.current = ctx
const analyser = ctx.createAnalyser()
analyser.fftSize = 64
analyser.smoothingTimeConstant = 0.8
analyserRef.current = analyser
}
// One MediaElementAudioSourceNode per audio element — never recreate
if (!sourceRef.current) {
const source = audioCtxRef.current.createMediaElementSource(audio)
sourceRef.current = source
source.connect(analyserRef.current!)
analyserRef.current!.connect(audioCtxRef.current.destination)
}
} catch {}
}, [])
const resumeContext = useCallback(() => {
if (audioCtxRef.current?.state === 'suspended') {
audioCtxRef.current.resume()
}
}, [])
return { audioRef, analyserRef, audioCtxRef, initAudioViz, resumeContext }
}

44
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
export function proxyImgUrl(url: string): string {
return `${API_URL}/api/proxy/img?url=${encodeURIComponent(url)}`
}
export function proxyMp3Url(url: string): string {
return url.startsWith('/api/proxy') ? `${API_URL}${url}` : url
}
export async function fetchTopCharts(): Promise<SearchResult[]> {
try {
const res = await fetch(`${API_URL}/api/proxy/top`, {
signal: AbortSignal.timeout(12_000),
})
if (!res.ok) return []
return res.json()
} catch {
return []
}
}
export async function fetchYandexPlaylist(yandexUrl: string): Promise<{ name: string; tracks: string[] }> {
const res = await fetch(`${API_URL}/api/proxy/yandex-playlist?url=${encodeURIComponent(yandexUrl)}`, {
signal: AbortSignal.timeout(15_000),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Ошибка загрузки плейлиста')
return data
}
export async function searchTracks(query: string): Promise<SearchResult[]> {
try {
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {
signal: AbortSignal.timeout(12_000),
})
if (!res.ok) return []
return res.json()
} catch {
return []
}
}

View File

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

View File

@@ -0,0 +1,94 @@
import type { User, Playlist, PublicPlaylist } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
function bearer(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` }
}
export async function register(username: string, email: string, password: string): Promise<User> {
return request<User>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, email, password }),
})
}
export async function login(email: string, password: string): Promise<{ token: string; user: User }> {
return request<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}
export async function fetchMe(token: string): Promise<User> {
return request<User>('/api/auth/me', { headers: bearer(token) })
}
export async function getPlaylists(token: string): Promise<Playlist[]> {
return request<Playlist[]>('/api/playlists', { headers: bearer(token) })
}
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
return request<PublicPlaylist[]>('/api/playlists/public')
}
export async function getPlaylist(token: string, id: string): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, { headers: bearer(token) })
}
export async function createPlaylist(
token: string,
name: string,
tracks: string[],
isPublic = false,
tags: string[] = [],
): Promise<Playlist> {
return request<Playlist>('/api/playlists', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
})
}
export async function updatePlaylist(
token: string,
id: string,
name: string,
tracks: string[],
isPublic = false,
tags: string[] = [],
): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
})
}
export async function addTrackToPlaylist(token: string, playlistId: string, title: string): Promise<void> {
return request<void>(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ title }),
})
}
export async function deletePlaylist(token: string, id: string): Promise<void> {
return request<void>(`/api/playlists/${id}`, {
method: 'DELETE',
headers: bearer(token),
})
}

View File

@@ -0,0 +1,33 @@
import type { Color } from '@/types'
export const COLORS: Color[] = [
{ bg: 'rgba(200,255,0,0.1)', text: '#c8ff00' },
{ bg: 'rgba(255,60,172,0.1)', text: '#ff3cac' },
{ bg: 'rgba(0,212,255,0.1)', text: '#00d4ff' },
{ bg: 'rgba(255,165,0,0.1)', text: '#ffa500' },
{ bg: 'rgba(140,100,255,0.1)', text: '#8c64ff' },
{ bg: 'rgba(0,255,140,0.1)', text: '#00ff8c' },
{ bg: 'rgba(255,80,80,0.1)', text: '#ff5050' },
]
export function initials(name: string): string {
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function hexToRgba(color: string, alpha: number): string {
if (color.startsWith('rgba') || color.startsWith('rgb')) {
return color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
}
if (color.startsWith('#')) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
return `rgba(200,255,0,${alpha})`
}

View File

@@ -0,0 +1,44 @@
import type { Person, QueueItem } from '@/types'
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
export function fairShuffle(people: Person[]): QueueItem[] {
const pools = people.map((p, pi) =>
shuffleArray(
p.tracks.map((t, ti) => ({
title: t.title,
owner: p.name,
color: p.color,
_pi: pi,
_ti: ti,
img: '',
})),
),
)
const order = shuffleArray([...Array(pools.length).keys()])
const result: QueueItem[] = []
const maxLen = Math.max(...pools.map((p) => p.length))
for (let r = 0; r < maxLen; r++) {
for (const pi of order) {
if (r < pools[pi].length) result.push(pools[pi][r])
}
}
return result
}
export function randomShuffle(people: Person[]): QueueItem[] {
const all: QueueItem[] = []
people.forEach((p, pi) =>
p.tracks.forEach((t, ti) =>
all.push({ title: t.title, owner: p.name, color: p.color, _pi: pi, _ti: ti, img: '' }),
),
)
return shuffleArray(all)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { create } from 'zustand'
import type { User } from '@/types'
import { fetchMe } from '@/lib/authApi'
const TOKEN_KEY = 'pm_token'
const USER_KEY = 'pm_user'
interface AuthStore {
token: string | null
user: User | null
setAuth: (token: string, user: User) => void
clearAuth: () => void
hydrate: () => Promise<void>
}
export const useAuthStore = create<AuthStore>((set, get) => ({
token: null,
user: null,
setAuth: (token, user) => {
if (typeof window !== 'undefined') {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
set({ token, user })
},
clearAuth: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
set({ token: null, user: null })
},
hydrate: async () => {
if (typeof window === 'undefined') return
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return
try {
const user = await fetchMe(token)
set({ token, user })
} catch {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
},
}))

View File

@@ -0,0 +1,55 @@
'use client'
import { create } from 'zustand'
const STORAGE_KEY = 'pm_favorites'
function loadFromStorage(): string[] {
if (typeof window === 'undefined') return []
try {
const s = localStorage.getItem(STORAGE_KEY)
return s ? (JSON.parse(s) as string[]) : []
} catch { return [] }
}
function saveToStorage(favorites: string[]) {
if (typeof window === 'undefined') return
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)) } catch {}
}
interface FavoritesStore {
favorites: string[]
hydrate: () => void
toggleFavorite: (title: string) => void
isFavorite: (title: string) => boolean
removeFavorite: (title: string) => void
clearFavorites: () => void
}
export const useFavoritesStore = create<FavoritesStore>((set, get) => ({
favorites: [],
hydrate: () => set({ favorites: loadFromStorage() }),
toggleFavorite: (title) => {
const { favorites } = get()
const next = favorites.includes(title)
? favorites.filter((f) => f !== title)
: [title, ...favorites]
saveToStorage(next)
set({ favorites: next })
},
isFavorite: (title) => get().favorites.includes(title),
removeFavorite: (title) => {
const next = get().favorites.filter((f) => f !== title)
saveToStorage(next)
set({ favorites: next })
},
clearFavorites: () => {
saveToStorage([])
set({ favorites: [] })
},
}))

View File

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

View File

@@ -0,0 +1,95 @@
'use client'
import { create } from 'zustand'
import type { SearchResult } from '@/types'
import { useAuthStore } from '@/store/authStore'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const STORAGE_KEY = 'pm_versions'
interface SavedVersion {
title: string
artist: string
duration: string
}
interface VersionStore {
versions: Record<string, SavedVersion>
hydrate: () => Promise<void>
saveVersion: (trackTitle: string, result: SearchResult) => void
getSavedVersion: (trackTitle: string) => SavedVersion | undefined
removeVersion: (trackTitle: string) => void
isSaved: (trackTitle: string, result: SearchResult) => boolean
}
function loadLocal(): Record<string, SavedVersion> {
if (typeof window === 'undefined') return {}
try {
const s = localStorage.getItem(STORAGE_KEY)
return s ? JSON.parse(s) : {}
} catch { return {} }
}
function saveLocal(v: Record<string, SavedVersion>) {
if (typeof window === 'undefined') return
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) } catch {}
}
function getToken(): string | null {
return useAuthStore.getState().token
}
async function apiFetch(path: string, opts?: RequestInit): Promise<Response | null> {
const token = getToken()
if (!token) return null
return fetch(`${API_URL}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...opts?.headers },
}).catch(() => null)
}
export const useVersionStore = create<VersionStore>((set, get) => ({
versions: {},
hydrate: async () => {
const local = loadLocal()
set({ versions: local })
const res = await apiFetch('/api/versions')
if (res?.ok) {
const remote: Record<string, SavedVersion> = await res.json()
// Merge: remote is authoritative, but keep local entries not yet on server
const merged = { ...local, ...remote }
saveLocal(merged)
set({ versions: merged })
}
},
saveVersion: (trackTitle, result) => {
const v: SavedVersion = { title: result.title, artist: result.artist, duration: result.duration }
const next = { ...get().versions, [trackTitle]: v }
saveLocal(next)
set({ versions: next })
apiFetch('/api/versions', {
method: 'POST',
body: JSON.stringify({ track_title: trackTitle, title: v.title, artist: v.artist, duration: v.duration }),
})
},
getSavedVersion: (trackTitle) => get().versions[trackTitle],
removeVersion: (trackTitle) => {
const next = { ...get().versions }
delete next[trackTitle]
saveLocal(next)
set({ versions: next })
apiFetch('/api/versions', {
method: 'DELETE',
body: JSON.stringify({ track_title: trackTitle }),
})
},
isSaved: (trackTitle, result) => {
const v = get().versions[trackTitle]
return !!v && v.title === result.title && v.artist === result.artist && v.duration === result.duration
},
}))

View File

@@ -0,0 +1,70 @@
export interface Color {
bg: string
text: string
}
export interface SearchResult {
mp3: string
title: string
artist: string
duration: string
img: string
}
export interface PersonTrack {
title: string
}
export interface Person {
name: string
color: Color
tracks: PersonTrack[]
}
export interface QueueItem {
title: string
owner: string
color: Color
_pi: number
_ti: number
img: string
}
export interface HistoryEntry {
title: string
artist: string
img: string
owner: string
color: Color
playedAt: string
}
export type ShuffleMode = 'fair' | 'random'
export interface User {
id: string
username: string
email: string
created_at: string
}
export interface PlaylistTrack {
id: string
playlist_id: string
title: string
position: number
}
export interface Playlist {
id: string
user_id: string
name: string
is_public: boolean
tags?: string[]
created_at: string
tracks?: PlaylistTrack[]
}
export interface PublicPlaylist extends Playlist {
username: string
}

View File

@@ -0,0 +1,49 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
bg: '#0a0a0f',
surface: '#12121a',
surface2: '#1a1a26',
accent: '#c8ff00',
muted: '#555555',
'app-text': '#f0f0f0',
},
fontFamily: {
sans: ['var(--font-dm-sans)', 'DM Sans', 'sans-serif'],
display: ['var(--font-syne)', 'Syne', 'sans-serif'],
},
borderRadius: {
app: '16px',
},
keyframes: {
fadeUp: {
from: { opacity: '0', transform: 'translateY(6px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
coverPulse: {
from: { transform: 'scale(1)' },
to: { transform: 'scale(1.06)' },
},
glowPulse: {
from: { opacity: '0.4' },
to: { opacity: '0.9' },
},
},
animation: {
fadeUp: 'fadeUp 0.25s ease both',
coverPulse: 'coverPulse 2s ease-in-out infinite alternate',
glowPulse: 'glowPulse 1.5s ease-in-out infinite alternate',
},
maxWidth: {
app: '660px',
},
},
},
plugins: [],
}
export default config

20
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

6
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,6 @@
version: '3.9'
services:
postgres:
ports:
- "5432:5432"

54
docker-compose.yml Normal file
View File

@@ -0,0 +1,54 @@
version: '3.9'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: partymix
POSTGRES_USER: partymix
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U partymix"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
build:
context: ./apps/backend
dockerfile: Dockerfile
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: partymix
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: partymix
GIN_MODE: release
PORT: 8080
JWT_SECRET: ${JWT_SECRET}
ports:
- "8081:8080"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
web:
build:
context: ./apps/web
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
ports:
- "3001:3000"
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:

771
index.html Normal file
View File

@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Party Mix</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0a0f;--surface:#12121a;--surface2:#1a1a26;
--border:rgba(255,255,255,0.07);--accent:#c8ff00;
--text:#f0f0f0;--muted:#555;--radius:16px;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html{scroll-behavior:smooth}
body{
font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);
min-height:100vh;padding:1.25rem 1rem 5rem;
background-image:
radial-gradient(ellipse 80% 50% at 20% -10%,rgba(200,255,0,0.05) 0%,transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 110%,rgba(255,60,172,0.05) 0%,transparent 60%);
}
.app{max-width:660px;margin:0 auto}
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── HEADER ── */
.header{display:flex;align-items:center;gap:12px;margin-bottom:1.25rem;padding-bottom:1.25rem;border-bottom:1px solid var(--border)}
.logo{width:40px;height:40px;border-radius:11px;background:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.logo svg{width:20px;height:20px}
h1{font-family:'Syne',sans-serif;font-size:20px;font-weight:800;letter-spacing:-.5px}
.header-sub{font-size:12px;color:var(--muted);margin-top:1px}
/* ── TABS ── */
.tabs{display:flex;gap:0;margin-bottom:1rem;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:3px}
.tab-btn{flex:1;padding:8px;font-size:13px;font-family:'Syne',sans-serif;font-weight:700;letter-spacing:.2px;border:none;border-radius:9px;cursor:pointer;background:transparent;color:var(--muted);transition:all .2s;display:flex;align-items:center;justify-content:center;gap:6px}
.tab-btn.active{background:var(--surface2);color:var(--text)}
.tab-btn .badge{background:var(--accent);color:#0a0a0f;font-size:10px;font-weight:800;padding:1px 5px;border-radius:4px;min-width:18px;text-align:center}
.tab-content{display:none}
.tab-content.active{display:block;animation:fadeUp .25s ease both}
/* ── PLAYER ── */
.player-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:1rem;display:none;overflow:hidden;animation:fadeUp .3s ease both}
.player-hero{display:flex;gap:0;position:relative;overflow:hidden;border-bottom:1px solid var(--border);min-height:110px}
.cover-wrap{position:relative;flex-shrink:0;width:110px;height:110px;overflow:hidden;background:var(--surface2)}
.cover-img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .5s ease}
.cover-img.playing{animation:coverPulse 2s ease-in-out infinite alternate}
@keyframes coverPulse{from{transform:scale(1)}to{transform:scale(1.06)}}
.cover-canvas{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .4s;pointer-events:none}
.cover-canvas.active{opacity:1}
.cover-glow{position:absolute;inset:-20px;border-radius:50%;background:radial-gradient(circle,var(--glow-color,rgba(200,255,0,.25)) 0%,transparent 70%);opacity:0;transition:opacity .5s;filter:blur(12px);pointer-events:none;z-index:0}
.cover-wrap.playing .cover-glow{opacity:1;animation:glowPulse 1.5s ease-in-out infinite alternate}
@keyframes glowPulse{from{opacity:.4}to{opacity:.9}}
.player-info{flex:1;padding:.9rem 1rem;display:flex;flex-direction:column;justify-content:center;gap:4px;background:linear-gradient(135deg,var(--surface2) 0%,var(--surface) 100%);min-width:0}
.now-eyebrow{font-size:10px;font-weight:500;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);display:flex;align-items:center;gap:5px}
.eq-bars{display:flex;align-items:flex-end;gap:2px;height:10px}
.eq-bar{width:2.5px;border-radius:2px;background:var(--accent);animation:eqAnim .7s ease-in-out infinite alternate;height:100%}
.eq-bar:nth-child(2){animation-delay:.15s;height:60%}
.eq-bar:nth-child(3){animation-delay:.3s;height:80%}
.eq-bar:nth-child(4){animation-delay:.1s;height:50%}
@keyframes eqAnim{from{transform:scaleY(.3)}to{transform:scaleY(1)}}
.now-title{font-family:'Syne',sans-serif;font-size:15px;font-weight:800;letter-spacing:-.3px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.owner-badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:3px 9px;border-radius:7px;align-self:flex-start}
.search-status{padding:.75rem 1rem;display:none;align-items:center;gap:10px;font-size:13px;color:var(--muted);border-bottom:1px solid var(--border)}
.spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--surface2);border-top-color:var(--accent);animation:spin .6s linear infinite;flex-shrink:0}
.audio-wrap{padding:.75rem 1rem;border-bottom:1px solid var(--border);display:none}
.audio-meta{font-size:12px;color:var(--muted);margin-bottom:7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
audio{width:100%;height:34px;border-radius:8px;outline:none;filter:invert(1) hue-rotate(180deg) saturate(0.5)}
.results-wrap{display:none}
.results-header{padding:7px 1rem;font-size:10px;font-weight:600;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;transition:background .12s}
.results-header:hover{background:var(--surface2)}
.results-chevron{font-size:10px;transition:transform .2s}
.results-chevron.open{transform:rotate(180deg)}
.results-body{max-height:0;overflow:hidden;transition:max-height .3s ease}
.results-body.open{max-height:600px}
.result-item{display:flex;align-items:center;gap:9px;padding:9px 1rem;border-bottom:1px solid var(--border);cursor:pointer;transition:background .12s}
.result-item:last-child{border-bottom:none}
.result-item:hover{background:var(--surface2)}
.result-item.active{background:rgba(200,255,0,0.04)}
.res-cover{width:32px;height:32px;border-radius:6px;object-fit:cover;flex-shrink:0;background:var(--surface2)}
.result-num{font-size:11px;color:var(--muted);width:14px;flex-shrink:0;text-align:right;font-family:'Syne',sans-serif}
.result-info{flex:1;min-width:0}
.result-name{font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
.result-artist{font-size:11px;color:var(--muted);margin-top:1px}
.result-dur{font-size:11px;color:var(--muted);flex-shrink:0;font-family:'Syne',sans-serif}
.result-play-btn{width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s}
.result-play-btn:hover,.result-play-btn.active{background:var(--accent);border-color:var(--accent)}
.result-play-btn svg{fill:var(--muted);transition:fill .15s}
.result-play-btn:hover svg,.result-play-btn.active svg{fill:#0a0a0f}
.player-controls{display:flex;align-items:center;gap:8px;padding:.65rem 1rem}
.ctrl{width:36px;height:36px;border-radius:9px;border:1px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--muted);flex-shrink:0;transition:all .15s}
.ctrl:hover{background:var(--surface2);color:var(--text)}
.ctrl-info{flex:1;text-align:center;font-size:12px;color:var(--muted);font-family:'Syne',sans-serif}
.btn-next{padding:6px 12px;font-size:12px;font-weight:600;font-family:'Syne',sans-serif;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap}
.btn-next:hover{background:var(--surface2);color:var(--text)}
/* ── QUEUE ── */
.queue-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:1rem}
.queue-head{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;cursor:pointer;user-select:none;transition:background .12s}
.queue-head:hover{background:var(--surface2)}
.queue-head-left{display:flex;align-items:center;gap:8px}
.queue-label{font-family:'Syne',sans-serif;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted)}
.queue-chevron{color:var(--muted);font-size:11px;transition:transform .25s;flex-shrink:0}
.queue-chevron.open{transform:rotate(180deg)}
.queue-body{max-height:0;overflow:hidden;transition:max-height .35s ease}
.queue-body.open{max-height:500px}
.queue-list{display:flex;flex-direction:column;max-height:500px;overflow-y:auto}
.queue-list::-webkit-scrollbar{width:3px}
.queue-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
/* DRAG & DROP */
.q-item{
display:flex;align-items:center;gap:8px;
padding:8px 1rem;border-bottom:1px solid var(--border);
cursor:pointer;transition:background .1s;
user-select:none;position:relative;
}
.q-item:last-child{border-bottom:none}
.q-item:hover{background:var(--surface2)}
.q-item.active{background:rgba(200,255,0,0.04)}
.q-item.dragging{opacity:.4;background:var(--surface2)}
.q-item.drag-over{border-top:2px solid var(--accent)}
.drag-handle{
color:var(--muted);cursor:grab;flex-shrink:0;
padding:4px 2px;opacity:.4;transition:opacity .15s;
display:flex;align-items:center;touch-action:none;
}
.q-item:hover .drag-handle{opacity:.8}
.drag-handle:active{cursor:grabbing}
.q-num{font-size:11px;color:var(--muted);width:18px;text-align:right;flex-shrink:0;font-family:'Syne',sans-serif}
.q-cover{width:28px;height:28px;border-radius:5px;object-fit:cover;flex-shrink:0;background:var(--surface2)}
.q-track{flex:1;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.q-owner{font-size:10px;padding:2px 6px;border-radius:5px;flex-shrink:0;font-weight:500}
.bars{width:12px;height:12px;display:flex;align-items:flex-end;gap:1.5px;flex-shrink:0}
.bar{width:2.5px;border-radius:1px;background:var(--accent);animation:bA .7s ease-in-out infinite alternate}
.bar:nth-child(2){animation-delay:.15s}.bar:nth-child(3){animation-delay:.3s}
@keyframes bA{from{height:2px}to{height:11px}}
/* ── HISTORY TAB ── */
.history-empty{text-align:center;padding:3rem 1rem;color:var(--muted);font-size:13px}
.history-empty svg{margin-bottom:.75rem;opacity:.3}
.history-item{display:flex;align-items:center;gap:10px;padding:10px 1rem;border-bottom:1px solid var(--border);animation:fadeUp .2s ease both}
.history-item:last-child{border-bottom:none}
.h-cover{width:40px;height:40px;border-radius:8px;object-fit:cover;flex-shrink:0;background:var(--surface2)}
.h-info{flex:1;min-width:0}
.h-title{font-size:13px;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.h-sub{font-size:11px;color:var(--muted);margin-top:2px;display:flex;align-items:center;gap:6px}
.h-owner{font-size:10px;padding:1px 6px;border-radius:4px}
.h-time{font-size:10px;color:var(--muted)}
.btn-h-play{width:30px;height:30px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s}
.btn-h-play:hover{background:var(--accent);border-color:var(--accent)}
.btn-h-play:hover svg{fill:#0a0a0f}
.btn-h-play svg{fill:var(--muted);transition:fill .15s}
.history-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.history-head{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--border)}
.history-label{font-family:'Syne',sans-serif;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted)}
.btn-clear{font-size:11px;padding:3px 9px;border:1px solid rgba(255,100,100,0.2);border-radius:7px;background:transparent;color:rgba(255,100,100,0.5);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s}
.btn-clear:hover{background:rgba(255,100,100,0.08);color:#ff6b6b;border-color:rgba(255,100,100,0.35)}
/* ── PARTY TAB (участники + форма) ── */
.btn-mix{
width:100%;padding:13px;margin-bottom:1rem;
font-family:'Syne',sans-serif;font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;
background:var(--accent);border:none;border-radius:var(--radius);
color:#0a0a0f;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;
transition:opacity .15s;
}
.btn-mix:hover{opacity:.9}
.btn-mix:active{transform:scale(.99)}
.mode-toggle{display:flex;gap:5px;margin-bottom:1rem;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:4px}
.mode-btn{flex:1;padding:7px;font-size:12px;font-family:'Syne',sans-serif;font-weight:600;letter-spacing:.2px;border:none;border-radius:7px;cursor:pointer;background:transparent;color:var(--muted);transition:all .2s}
.mode-btn.active{background:var(--surface2);color:var(--text)}
.people-list{display:flex;flex-direction:column;gap:.6rem;margin-bottom:1rem}
.p-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;animation:fadeUp .3s ease both}
.p-head{display:flex;align-items:center;gap:9px;padding:.75rem 1rem;cursor:pointer;user-select:none;transition:background .12s}
.p-head:hover{background:var(--surface2)}
.avatar{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-family:'Syne',sans-serif;font-size:11px;font-weight:800;flex-shrink:0}
.p-name{font-family:'Syne',sans-serif;font-size:13px;font-weight:700;flex:1;letter-spacing:-.1px}
.p-count{font-size:11px;color:var(--muted);flex-shrink:0}
.p-chevron{color:var(--muted);flex-shrink:0;transition:transform .2s;font-size:11px}
.p-chevron.open{transform:rotate(180deg)}
.btn-del{width:24px;height:24px;border-radius:7px;border:1px solid rgba(255,100,100,0.15);background:transparent;color:rgba(255,100,100,0.4);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0}
.btn-del:hover{background:rgba(255,100,100,0.1);color:#ff6b6b;border-color:rgba(255,100,100,0.35)}
.p-chips-wrap{border-top:1px solid var(--border);max-height:0;overflow:hidden;transition:max-height .3s ease}
.p-chips-wrap.open{max-height:500px}
.chips{display:flex;flex-wrap:wrap;gap:4px;padding:.7rem 1rem}
.chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:3px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--muted);max-width:220px}
.chip span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:165px}
.chip.playing{background:rgba(200,255,0,0.07);border-color:rgba(200,255,0,0.25);color:var(--accent);font-weight:500}
.dot{width:4px;height:4px;border-radius:50%;background:#4ade80;flex-shrink:0}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.1rem;margin-bottom:1rem}
input[type=text],textarea{font-family:'DM Sans',sans-serif;font-size:13px;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:9px 13px;color:var(--text);outline:none;width:100%;transition:border-color .2s}
input[type=text]:focus,textarea:focus{border-color:rgba(200,255,0,0.35)}
.hint{font-size:12px;color:var(--muted);margin-bottom:6px;line-height:1.5}
textarea{min-height:80px;resize:vertical;line-height:1.6;margin-top:8px}
.err-msg{font-size:12px;color:#ff6b6b;background:rgba(255,107,107,0.08);padding:7px 11px;border-radius:8px;margin-top:6px;display:none;border:1px solid rgba(255,107,107,0.15)}
.btn-add{width:100%;padding:11px;margin-top:9px;font-family:'Syne',sans-serif;font-size:13px;font-weight:700;letter-spacing:.4px;background:var(--surface2);border:1px solid var(--border);border-radius:9px;color:var(--text);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;transition:all .2s}
.btn-add:hover{border-color:rgba(200,255,0,0.3);color:var(--accent)}
.btn{padding:6px 12px;font-size:12px;font-family:'DM Sans',sans-serif;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;transition:all .15s}
.btn:hover{background:var(--surface2);color:var(--text)}
/* ── MOBILE ── */
@media(max-width:500px){
body{padding:1rem .75rem 5rem}
h1{font-size:18px}
.header-sub{display:none}
.player-hero{min-height:96px}
.cover-wrap{width:96px;height:96px}
.now-title{font-size:13px}
.owner-badge{font-size:10px;padding:2px 7px}
.ctrl{width:32px;height:32px}
.player-controls{gap:6px;padding:.6rem .75rem}
.ctrl-info{font-size:11px}
.btn-next{padding:5px 10px;font-size:11px}
.audio-wrap{padding:.65rem .75rem}
.result-item{padding:8px .75rem}
.q-item{padding:8px .75rem}
.history-item{padding:9px .75rem}
.queue-head{padding:.7rem .75rem}
.chips{padding:.6rem .75rem}
.p-head{padding:.7rem .75rem}
.card{padding:.9rem .75rem}
.tab-btn{font-size:12px;padding:7px 4px}
.btn-mix{font-size:13px;padding:12px}
.mode-btn{font-size:11px;padding:6px 3px}
.results-header{padding:6px .75rem}
.player-info{padding:.75rem .85rem}
.queue-label{font-size:10px}
}
@media(max-width:360px){
.cover-wrap{width:80px;height:80px}
.now-title{font-size:12px}
.tab-btn span:not(.badge){display:none}
}
</style>
</head>
<body>
<div class="app">
<!-- Header -->
<div class="header">
<div class="logo">
<svg viewBox="0 0 24 24" fill="none"><path d="M9 18V5l12-2v13" stroke="#0a0a0f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="18" r="3" fill="#0a0a0f"/><circle cx="18" cy="16" r="3" fill="#0a0a0f"/></svg>
</div>
<div>
<h1>Party Mix</h1>
<p class="header-sub">Музыка с hitmotop.com</p>
</div>
</div>
<!-- PLAYER (всегда виден поверх вкладок) -->
<div class="player-card" id="playerCard">
<div class="player-hero">
<div class="cover-wrap" id="coverWrap">
<div class="cover-glow" id="coverGlow"></div>
<img class="cover-img" id="coverImg" src="" alt=""/>
<canvas class="cover-canvas" id="coverCanvas"></canvas>
</div>
<div class="player-info">
<div class="now-eyebrow">
<div class="eq-bars"><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div></div>
Сейчас играет
</div>
<div class="now-title" id="nowTitle"></div>
<span class="owner-badge" id="nowOwner"></span>
</div>
</div>
<div id="searchStatus" class="search-status">
<div class="spinner"></div><span id="searchMsg">Ищем...</span>
</div>
<div class="audio-wrap" id="audioWrap">
<div class="audio-meta"><span id="audioName"></span><span style="margin:0 5px;opacity:.3">·</span><span id="audioArtist"></span></div>
<audio id="audioEl" controls preload="auto" crossorigin="anonymous"></audio>
</div>
<div class="results-wrap" id="resultsWrap">
<div class="results-header" onclick="toggleResults()">
<span>Найдено — выберите версию</span>
<span class="results-chevron" id="resultsChevron"></span>
</div>
<div class="results-body" id="resultsBody"><div id="resultsList"></div></div>
</div>
<div class="player-controls">
<button class="ctrl" onclick="prevTrack()"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg></button>
<button class="ctrl" onclick="reloadTrack()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4"/></svg></button>
<button class="ctrl" onclick="nextTrack()"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z"/></svg></button>
<div class="ctrl-info" id="ctrlInfo">— / —</div>
<button class="btn-next" onclick="nextTrack()">Далее →</button>
</div>
</div>
<!-- QUEUE (под плеером) -->
<div class="queue-card" id="queueCard" style="display:none">
<div class="queue-head" onclick="toggleQueue()">
<div class="queue-head-left">
<span class="queue-label" id="queueLabel">Очередь</span>
<button class="btn" onclick="event.stopPropagation();generateMix()" style="padding:3px 9px;font-size:11px"></button>
</div>
<span class="queue-chevron" id="queueChevron"></span>
</div>
<div class="queue-body" id="queueBody">
<div class="queue-list" id="queueList"></div>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('party')" id="tab-party">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Участники</span>
</button>
<button class="tab-btn" onclick="switchTab('history')" id="tab-history">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>История</span>
<span class="badge" id="historyBadge" style="display:none">0</span>
</button>
</div>
<!-- TAB: PARTY -->
<div class="tab-content active" id="tab-content-party">
<div class="mode-toggle" id="modeToggle" style="display:none">
<button class="mode-btn active" id="modeFair" onclick="setMode('fair')">⚖️ По очереди</button>
<button class="mode-btn" id="modeRandom" onclick="setMode('random')">🎲 Случайно</button>
</div>
<button class="btn-mix" onclick="generateMix()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
Перемешать и включить
</button>
<div id="peopleList" class="people-list"></div>
<div class="card">
<input type="text" id="nameIn" placeholder="Имя участника"/>
<div class="hint" style="margin-top:8px">Список треков — каждый с новой строки. Формат: <b style="color:var(--text);font-weight:500">Исполнитель — Название</b></div>
<textarea id="tracksIn" placeholder="Тараканы - Пойдём на улицу&#10;PALC - Залип&#10;Дора - Втюрилась&#10;..."></textarea>
<div class="err-msg" id="errMsg"></div>
<button class="btn-add" onclick="addPerson()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12l7-7 7 7"/></svg>
Добавить участника
</button>
</div>
</div>
<!-- TAB: HISTORY -->
<div class="tab-content" id="tab-content-history">
<div class="history-card">
<div class="history-head">
<span class="history-label">Уже сыграло</span>
<button class="btn-clear" onclick="clearHistory()">Очистить</button>
</div>
<div id="historyList">
<div class="history-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<div>История пуста</div>
<div style="font-size:11px;margin-top:4px;opacity:.6">Здесь появятся треки которые уже сыграли</div>
</div>
</div>
</div>
</div>
</div>
<script>
const COLORS=[
{bg:'rgba(200,255,0,0.1)',text:'#c8ff00'},
{bg:'rgba(255,60,172,0.1)',text:'#ff3cac'},
{bg:'rgba(0,212,255,0.1)',text:'#00d4ff'},
{bg:'rgba(255,165,0,0.1)',text:'#ffa500'},
{bg:'rgba(140,100,255,0.1)',text:'#8c64ff'},
{bg:'rgba(0,255,140,0.1)',text:'#00ff8c'},
{bg:'rgba(255,80,80,0.1)',text:'#ff5050'},
];
let people=[], queue=[], curIdx=-1, currentResults=[];
let shuffleMode='fair';
const collapsed={};
let queueOpen=false;
let history=[];
// ── TABS ──
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
document.getElementById('tab-'+tab).classList.add('active');
document.getElementById('tab-content-'+tab).classList.add('active');
}
// ── WEB AUDIO VIZ ──
let audioCtx=null,analyser=null,source=null,rafId=null;
function initAudioViz(el){
try{
if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)();analyser=audioCtx.createAnalyser();analyser.fftSize=64;analyser.smoothingTimeConstant=0.8;}
if(source){try{source.disconnect();}catch(e){}}
source=audioCtx.createMediaElementSource(el);
source.connect(analyser);analyser.connect(audioCtx.destination);
}catch(e){analyser=null;}
}
function startViz(){
if(!analyser)return;
const canvas=document.getElementById('coverCanvas'),wrap=document.getElementById('coverWrap'),img=document.getElementById('coverImg');
canvas.classList.add('active');wrap.classList.add('playing');img.classList.add('playing');
const ctx=canvas.getContext('2d');
canvas.width=canvas.offsetWidth;canvas.height=canvas.offsetHeight;
const W=canvas.width,H=canvas.height,data=new Uint8Array(analyser.frequencyBinCount);
function draw(){
rafId=requestAnimationFrame(draw);
analyser.getByteFrequencyData(data);
ctx.clearRect(0,0,W,H);
const barCount=data.length,barW=W/barCount,maxH=H*0.55;
for(let i=0;i<barCount;i++){
const v=data[i]/255,bh=v*maxH,x=i*barW,hue=80+i*3;
const g=ctx.createLinearGradient(0,H,0,H-bh);
g.addColorStop(0,`hsla(${hue},100%,60%,.9)`);g.addColorStop(1,`hsla(${hue},100%,80%,.1)`);
ctx.fillStyle=g;ctx.fillRect(x,H-bh,barW*.7,bh);
}
const ov=ctx.createLinearGradient(0,0,0,H);
ov.addColorStop(0,'rgba(10,10,15,.3)');ov.addColorStop(.5,'rgba(10,10,15,0)');ov.addColorStop(1,'rgba(10,10,15,.6)');
ctx.fillStyle=ov;ctx.fillRect(0,0,W,H);
}
draw();
}
function stopViz(){
if(rafId){cancelAnimationFrame(rafId);rafId=null;}
const canvas=document.getElementById('coverCanvas'),wrap=document.getElementById('coverWrap'),img=document.getElementById('coverImg');
canvas.classList.remove('active');wrap.classList.remove('playing');img.classList.remove('playing');
canvas.getContext('2d').clearRect(0,0,canvas.width,canvas.height);
}
function hexToRgba(c,a){if(c.startsWith('rgb'))return c.replace('rgb','rgba').replace(')',`,${a})`);if(c.startsWith('#')){const r=parseInt(c.slice(1,3),16),g=parseInt(c.slice(3,5),16),b=parseInt(c.slice(5,7),16);return`rgba(${r},${g},${b},${a})`;}return`rgba(200,255,0,${a})`;}
// ── SHUFFLE ──
function fairShuffle(ppl){
const pools=ppl.map((p,pi)=>{
const arr=p.tracks.map((t,ti)=>({title:t.title,owner:p.name,color:p.color,_pi:pi,_ti:ti,img:''}));
for(let i=arr.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[arr[i],arr[j]]=[arr[j],arr[i]];}
return arr;
});
const order=[...Array(pools.length).keys()];
for(let i=order.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[order[i],order[j]]=[order[j],order[i]];}
const result=[];
const maxLen=Math.max(...pools.map(p=>p.length));
for(let r=0;r<maxLen;r++)for(const pi of order)if(r<pools[pi].length)result.push(pools[pi][r]);
return result;
}
function randomShuffle(ppl){
const all=[];
ppl.forEach((p,pi)=>p.tracks.forEach((t,ti)=>all.push({title:t.title,owner:p.name,color:p.color,_pi:pi,_ti:ti,img:''})));
for(let i=all.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[all[i],all[j]]=[all[j],all[i]];}
return all;
}
function setMode(m){shuffleMode=m;document.getElementById('modeFair').classList.toggle('active',m==='fair');document.getElementById('modeRandom').classList.toggle('active',m==='random');}
// ── QUEUE TOGGLE ──
function toggleQueue(){queueOpen=!queueOpen;document.getElementById('queueBody').classList.toggle('open',queueOpen);document.getElementById('queueChevron').classList.toggle('open',queueOpen);}
function toggleResults(){const b=document.getElementById('resultsBody'),c=document.getElementById('resultsChevron'),o=b.classList.toggle('open');c.classList.toggle('open',o);}
// ── DRAG & DROP ──
let dragSrcIdx=null;
function initDrag(){
const items=document.querySelectorAll('.q-item[draggable]');
items.forEach((item,i)=>{
item.addEventListener('dragstart',e=>{dragSrcIdx=parseInt(item.dataset.idx);item.classList.add('dragging');e.dataTransfer.effectAllowed='move';});
item.addEventListener('dragend',()=>{item.classList.remove('dragging');document.querySelectorAll('.q-item').forEach(it=>it.classList.remove('drag-over'));});
item.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='move';item.classList.add('drag-over');});
item.addEventListener('dragleave',()=>item.classList.remove('drag-over'));
item.addEventListener('drop',e=>{
e.preventDefault();item.classList.remove('drag-over');
const tgtIdx=parseInt(item.dataset.idx);
if(dragSrcIdx===null||dragSrcIdx===tgtIdx)return;
// Перемещаем в очереди
const moved=queue.splice(dragSrcIdx,1)[0];
queue.splice(tgtIdx,0,moved);
// Корректируем curIdx
if(curIdx===dragSrcIdx)curIdx=tgtIdx;
else if(dragSrcIdx<curIdx&&tgtIdx>=curIdx)curIdx--;
else if(dragSrcIdx>curIdx&&tgtIdx<=curIdx)curIdx++;
dragSrcIdx=null;
renderQueue();
});
});
// Touch drag
initTouchDrag();
}
// Touch drag & drop
function initTouchDrag(){
let touchSrcIdx=null,clone=null,startY=0;
const list=document.getElementById('queueList');
list.querySelectorAll('.drag-handle').forEach(handle=>{
handle.addEventListener('touchstart',e=>{
const item=handle.closest('.q-item');
touchSrcIdx=parseInt(item.dataset.idx);
startY=e.touches[0].clientY;
item.classList.add('dragging');
e.preventDefault();
},{passive:false});
handle.addEventListener('touchmove',e=>{
if(touchSrcIdx===null)return;
e.preventDefault();
const y=e.touches[0].clientY;
const els=list.querySelectorAll('.q-item:not(.dragging)');
let targetIdx=null;
els.forEach(el=>{
const r=el.getBoundingClientRect();
if(y>r.top&&y<r.bottom)targetIdx=parseInt(el.dataset.idx);
el.classList.remove('drag-over');
});
if(targetIdx!==null){
const t=list.querySelector(`.q-item[data-idx="${targetIdx}"]`);
if(t)t.classList.add('drag-over');
}
},{passive:false});
handle.addEventListener('touchend',e=>{
if(touchSrcIdx===null)return;
const items=list.querySelectorAll('.q-item');
let tgtIdx=null;
items.forEach(el=>{if(el.classList.contains('drag-over'))tgtIdx=parseInt(el.dataset.idx);el.classList.remove('drag-over','dragging');});
if(tgtIdx!==null&&tgtIdx!==touchSrcIdx){
const moved=queue.splice(touchSrcIdx,1)[0];
queue.splice(tgtIdx,0,moved);
if(curIdx===touchSrcIdx)curIdx=tgtIdx;
else if(touchSrcIdx<curIdx&&tgtIdx>=curIdx)curIdx--;
else if(touchSrcIdx>curIdx&&tgtIdx<=curIdx)curIdx++;
renderQueue();
}
touchSrcIdx=null;
});
});
}
// ── SEARCH ──
async function searchHitmotop(query){
try{
const r=await fetch(`/search?q=${encodeURIComponent(query)}`,{signal:AbortSignal.timeout(10000)});
if(!r.ok)return[];
const html=await r.text();
if(!html.includes('track__download-btn'))return[];
const results=[];
const dlRegex=/href="(\/get\/music\/[^"]+\.mp3)"/g;
const metaRegex=/data-musmeta='([^']+)'/g;
const dlHrefs=[];let dlm;
while((dlm=dlRegex.exec(html))!==null)dlHrefs.push(dlm[1]);
const metas=[];let mm;
while((mm=metaRegex.exec(html))!==null){try{metas.push(JSON.parse(mm[1]));}catch(e){}}
for(let i=0;i<dlHrefs.length;i++){
const href=dlHrefs[i];if(!href)continue;
const meta=metas[i]||{};
const originalMp3='https://rus.hitmotop.com'+href;
const mp3=`/mp3?url=${encodeURIComponent(originalMp3)}`;
const hrefPos=html.indexOf(href);
const chunk=hrefPos>=0?html.substring(Math.max(0,hrefPos-800),hrefPos+200):'';
const dur=chunk.match(/track__time[^>]*>([^<]+)</)|| ['',''];
let title=meta.title||'',artist=meta.artist||'';
if(!title){const tm=chunk.match(/track__title[^>]*>([^<]+)</);title=tm?tm[1].trim():'';}
if(!artist){const am=chunk.match(/track__desc[^>]*>([^<]+)</);artist=am?am[1].trim():'';}
results.push({mp3,originalMp3,title,artist,duration:dur[1].trim(),img:meta.img||''});
}
return results;
}catch(e){return[];}
}
// ── PEOPLE ──
function initials(n){return n.split(' ').map(w=>w[0]).join('').toUpperCase().slice(0,2);}
function addPerson(){
const name=document.getElementById('nameIn').value.trim();
const raw=document.getElementById('tracksIn').value.trim();
const err=document.getElementById('errMsg');
err.style.display='none';
if(!name){document.getElementById('nameIn').focus();return;}
if(!raw){err.textContent='Вставьте список треков';err.style.display='block';return;}
const tracks=raw.split('\n').map(l=>l.trim()).filter(l=>l.length>1).slice(0,50);
if(!tracks.length){err.textContent='Не нашли треков.';err.style.display='block';return;}
const pi=people.length;
people.push({name,color:COLORS[pi%COLORS.length],tracks:tracks.map(t=>({title:t}))});
collapsed[pi]=false;
document.getElementById('nameIn').value='';
document.getElementById('tracksIn').value='';
renderPeople();
if(people.length>=2)document.getElementById('modeToggle').style.display='flex';
}
function removePerson(i){
people.splice(i,1);people.forEach((p,j)=>p.color=COLORS[j%COLORS.length]);
renderPeople();
if(people.length<2)document.getElementById('modeToggle').style.display='none';
}
function toggleCollapse(pi){
collapsed[pi]=!collapsed[pi];
document.getElementById(`chips-${pi}`)?.classList.toggle('open',!collapsed[pi]);
document.getElementById(`chev-${pi}`)?.classList.toggle('open',!collapsed[pi]);
}
function renderPeople(){
const c=document.getElementById('peopleList');
c.innerHTML=people.map((p,pi)=>{
const isOpen=!collapsed[pi];
const chips=p.tracks.map((t,ti)=>{
const isPlay=curIdx>=0&&queue[curIdx]&&queue[curIdx]._pi===pi&&queue[curIdx]._ti===ti;
return`<span class="chip${isPlay?' playing':''}"><div class="dot"></div><span>${t.title}</span></span>`;
}).join('');
return`<div class="p-card">
<div class="p-head" onclick="toggleCollapse(${pi})">
<div class="avatar" style="background:${p.color.bg};color:${p.color.text}">${initials(p.name)}</div>
<span class="p-name">${p.name}</span>
<span class="p-count">${p.tracks.length} тр.</span>
<span class="p-chevron${isOpen?' open':''}" id="chev-${pi}">▼</span>
<button class="btn-del" onclick="event.stopPropagation();removePerson(${pi})">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="p-chips-wrap${isOpen?' open':''}" id="chips-${pi}">
<div class="chips">${chips}</div>
</div>
</div>`;
}).join('');
}
// ── GENERATE ──
function generateMix(){
if(!people.length){alert('Добавьте участников!');return;}
queue=shuffleMode==='fair'?fairShuffle(people):randomShuffle(people);
curIdx=0;renderQueue();loadTrack(0);
}
// ── LOAD TRACK ──
async function loadTrack(i){
if(i<0||i>=queue.length)return;
curIdx=i;
const t=queue[i];
const audio=document.getElementById('audioEl');
stopViz();audio.pause();audio.src='';
document.getElementById('audioWrap').style.display='none';
document.getElementById('resultsWrap').style.display='none';
document.getElementById('playerCard').style.display='block';
document.getElementById('nowTitle').textContent=t.title;
const ob=document.getElementById('nowOwner');
ob.textContent=t.owner;ob.style.background=t.color.bg;ob.style.color=t.color.text;
document.getElementById('ctrlInfo').textContent=`${i+1} / ${queue.length}`;
document.getElementById('searchStatus').style.display='flex';
document.getElementById('searchMsg').textContent='Ищем...';
document.getElementById('coverImg').src='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="110" height="110"><rect fill="%231a1a26" width="110" height="110"/></svg>';
document.getElementById('playerCard').scrollIntoView({behavior:'smooth',block:'start'});
renderQueue();renderPeople();
const results=await searchHitmotop(t.title);
document.getElementById('searchStatus').style.display='none';
if(!results.length){
document.getElementById('searchStatus').style.display='flex';
document.getElementById('searchMsg').innerHTML=`Не найдено. <a href="https://rus.hitmotop.com/search?q=${encodeURIComponent(t.title)}" target="_blank" style="color:var(--accent);margin-left:4px">hitmotop ↗</a>`;
setTimeout(()=>{if(curIdx===i)nextTrack();},4000);
return;
}
currentResults=results;
if(results[0]?.img&&!results[0].img.includes('no-cover')){
const proxyImg=`/img?url=${encodeURIComponent(results[0].img)}`;
document.getElementById('coverImg').src=proxyImg;
queue[i].img=proxyImg;renderQueue();
}
if(results.length>1)renderResultsList(results);
playResult(0);
}
function renderResultsList(results){
document.getElementById('resultsWrap').style.display='block';
document.getElementById('resultsList').innerHTML=results.map((r,i)=>`
<div class="result-item" id="res-${i}" onclick="playResult(${i})">
<span class="result-num">${i+1}</span>
${r.img&&!r.img.includes('no-cover')?`<img class="res-cover" src="/img?url=${encodeURIComponent(r.img)}" onerror="this.style.display='none'"/>`:''}
<div class="result-info"><div class="result-name">${r.title}</div><div class="result-artist">${r.artist}</div></div>
<div class="result-dur">${r.duration}</div>
<button class="result-play-btn" id="rpb-${i}" onclick="event.stopPropagation();playResult(${i})">
<svg width="9" height="9" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>`).join('');
}
function playResult(idx){
if(!currentResults[idx])return;
const r=currentResults[idx];
document.querySelectorAll('.result-item').forEach((el,i)=>el.classList.toggle('active',i===idx));
document.querySelectorAll('.result-play-btn').forEach((el,i)=>el.classList.toggle('active',i===idx));
document.getElementById('audioName').textContent=r.title;
document.getElementById('audioArtist').textContent=r.artist;
if(r.img&&!r.img.includes('no-cover'))document.getElementById('coverImg').src=`/img?url=${encodeURIComponent(r.img)}`;
const audio=document.getElementById('audioEl');
stopViz();audio.pause();audio.onerror=()=>{};
audio.src=r.mp3;audio.load();
document.getElementById('audioWrap').style.display='block';
audio.play().then(()=>{if(audioCtx?.state==='suspended')audioCtx.resume();startViz();}).catch(()=>{});
audio.onplay=()=>{if(audioCtx?.state==='suspended')audioCtx.resume();startViz();};
audio.onpause=()=>stopViz();
audio.onended=()=>{
stopViz();
// Добавляем в историю
addToHistory({title:r.title,artist:r.artist,img:r.img,owner:queue[curIdx]?.owner||'',color:queue[curIdx]?.color||COLORS[0]});
if(curIdx<queue.length-1)loadTrack(curIdx+1);
};
}
document.getElementById('audioEl').addEventListener('play',function init(){initAudioViz(this);this.removeEventListener('play',init);},{once:true});
function prevTrack(){stopViz();if(curIdx>0)loadTrack(curIdx-1);}
function nextTrack(){stopViz();if(curIdx<queue.length-1)loadTrack(curIdx+1);}
function reloadTrack(){stopViz();loadTrack(curIdx);}
// ── HISTORY ──
function addToHistory(item){
item.playedAt=new Date().toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
history.unshift(item); // новые сверху
updateHistoryBadge();
renderHistory();
}
function updateHistoryBadge(){
const b=document.getElementById('historyBadge');
if(history.length>0){b.textContent=history.length;b.style.display='inline-block';}
else b.style.display='none';
}
function clearHistory(){history=[];updateHistoryBadge();renderHistory();}
function renderHistory(){
const el=document.getElementById('historyList');
if(!history.length){
el.innerHTML=`<div class="history-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<div>История пуста</div>
<div style="font-size:11px;margin-top:4px;opacity:.6">Здесь появятся треки которые уже сыграли</div>
</div>`;
return;
}
el.innerHTML=history.map((item,i)=>`
<div class="history-item" style="animation-delay:${i*0.03}s">
${item.img&&!item.img.includes('no-cover')?`<img class="h-cover" src="/img?url=${encodeURIComponent(item.img)}" onerror="this.style.display='none'"/>`:'<div class="h-cover"></div>'}
<div class="h-info">
<div class="h-title">${item.title}</div>
<div class="h-sub">
<span class="h-owner" style="background:${item.color.bg};color:${item.color.text}">${item.owner}</span>
<span class="h-time">${item.playedAt}</span>
</div>
</div>
</div>`).join('');
}
// ── QUEUE RENDER ──
function renderQueue(){
const qc=document.getElementById('queueCard'),ql=document.getElementById('queueList'),lbl=document.getElementById('queueLabel');
if(!queue.length){qc.style.display='none';return;}
qc.style.display='block';
lbl.textContent=`Очередь · ${queue.length} треков`;
ql.innerHTML=queue.map((t,i)=>{
const active=i===curIdx;
const ind=active
?`<div class="bars"><div class="bar"></div><div class="bar"></div><div class="bar"></div></div>`
:`<span class="q-num">${i+1}</span>`;
const thumb=t.img?`<img class="q-cover" src="${t.img}" onerror="this.style.display='none'"/>`:'<div class="q-cover"></div>';
return`<div class="q-item${active?' active':''}" data-idx="${i}" draggable="true" onclick="handleQueueClick(event,${i})">
<div class="drag-handle" title="Перетащить">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="5" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="15" cy="19" r="1.5"/></svg>
</div>
${ind}${thumb}
<span class="q-track">${t.title}</span>
<span class="q-owner" style="background:${t.color.bg};color:${t.color.text}">${t.owner}</span>
</div>`;
}).join('');
setTimeout(()=>{
document.querySelector('.q-item.active')?.scrollIntoView({block:'nearest'});
initDrag();
},50);
}
function handleQueueClick(e,i){
// Не реагируем на клик по drag handle
if(e.target.closest('.drag-handle'))return;
loadTrack(i);
}
</script>
</body>
</html>

184
server.js Normal file
View File

@@ -0,0 +1,184 @@
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const url = require('url');
const PORT = 3000;
function proxyRequest(targetUrl, res, extraHeaders = {}) {
const parsedTarget = new URL(targetUrl);
const options = {
hostname: parsedTarget.hostname,
path: parsedTarget.pathname + parsedTarget.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'ru-RU,ru;q=0.9',
'Referer': 'https://rus.hitmotop.com/',
'Origin': 'https://rus.hitmotop.com',
...extraHeaders
}
};
const req = https.request(options, (proxyRes) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
// Пробрасываем важные заголовки
const ct = proxyRes.headers['content-type'];
if (ct) res.setHeader('Content-Type', ct);
const cl = proxyRes.headers['content-length'];
if (cl) res.setHeader('Content-Length', cl);
const cr = proxyRes.headers['content-range'];
if (cr) res.setHeader('Content-Range', cr);
const ar = proxyRes.headers['accept-ranges'];
if (ar) res.setHeader('Accept-Ranges', ar);
res.statusCode = proxyRes.statusCode;
proxyRes.pipe(res);
});
req.on('error', (e) => {
res.statusCode = 500;
res.end('Proxy error: ' + e.message);
});
req.end();
}
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') { res.end(); return; }
// /img?url=... — проксируем обложки альбомов
if (parsed.pathname === '/img') {
const imgurl = parsed.query.url || '';
if (!imgurl.startsWith('http')) { res.statusCode = 400; res.end('Bad url'); return; }
const pu = new URL(imgurl);
const reqOpts = {
hostname: pu.hostname,
path: pu.pathname + pu.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://rus.hitmotop.com/',
}
};
const pr = https.request(reqOpts, (proxyRes) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=86400');
const ct = proxyRes.headers['content-type'] || 'image/jpeg';
res.setHeader('Content-Type', ct);
res.statusCode = proxyRes.statusCode;
proxyRes.pipe(res);
});
pr.on('error', () => { res.statusCode = 500; res.end(); });
pr.end();
return;
}
// /search?q=... — поиск треков
if (parsed.pathname === '/search') {
const q = parsed.query.q || '';
proxyRequest(`https://rus.hitmotop.com/search?q=${encodeURIComponent(q)}`, res);
return;
}
// /mp3?url=... — проксируем MP3 файл с правильным Referer
if (parsed.pathname === '/mp3') {
const mp3url = parsed.query.url || '';
console.log(`\n[MP3] Запрос: ${mp3url}`);
if (!mp3url.startsWith('https://rus.hitmotop.com/') && !mp3url.startsWith('https://hitmotop.com/')) {
console.log('[MP3] ОТКЛОНЁН — не hitmotop URL');
res.statusCode = 403;
res.end('Only hitmotop URLs allowed');
return;
}
// Поддержка Range requests для перемотки
const rangeHeader = req.headers['range'];
if (rangeHeader) console.log(`[MP3] Range: ${rangeHeader}`);
function fetchMp3(targetUrl, redirectCount) {
if (redirectCount > 5) {
console.log('[MP3] Слишком много редиректов!');
res.statusCode = 500;
res.end('Too many redirects');
return;
}
const parsedUrl = new URL(targetUrl);
const reqOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'audio/mpeg, audio/*, */*',
'Accept-Language': 'ru-RU,ru;q=0.9',
'Referer': 'https://rus.hitmotop.com/',
'Origin': 'https://rus.hitmotop.com',
...(rangeHeader ? { 'Range': rangeHeader } : {})
}
};
const proxyReq = https.request(reqOptions, (proxyRes) => {
console.log(`[MP3] HTTP ${proxyRes.statusCode}${targetUrl.substring(0,80)}`);
console.log(`[MP3] Content-Type: ${proxyRes.headers['content-type']}`);
// Следуем за редиректом
if ((proxyRes.statusCode === 301 || proxyRes.statusCode === 302 || proxyRes.statusCode === 307 || proxyRes.statusCode === 308) && proxyRes.headers['location']) {
const location = proxyRes.headers['location'];
const nextUrl = location.startsWith('http') ? location : `https://${parsedUrl.hostname}${location}`;
console.log(`[MP3] Редирект → ${nextUrl}`);
proxyRes.resume(); // дочитываем тело чтобы освободить соединение
fetchMp3(nextUrl, redirectCount + 1);
return;
}
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'audio/mpeg');
['content-length','content-range','accept-ranges'].forEach(h => {
if (proxyRes.headers[h]) res.setHeader(h, proxyRes.headers[h]);
});
res.statusCode = proxyRes.statusCode;
proxyRes.pipe(res);
proxyRes.on('end', () => console.log('[MP3] ✓ Передача завершена'));
});
proxyReq.on('error', (e) => {
console.log(`[MP3] ОШИБКА: ${e.message}`);
if (!res.headersSent) {
res.statusCode = 500;
res.end('Proxy error: ' + e.message);
}
});
proxyReq.end();
}
fetchMp3(mp3url, 0);
return;
}
// / или /index.html — отдаём приложение
if (parsed.pathname === '/' || parsed.pathname === '/index.html') {
const filePath = path.join(__dirname, 'index.html');
fs.readFile(filePath, (err, data) => {
if (err) { res.statusCode = 404; res.end('Not found'); return; }
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(data);
});
return;
}
res.statusCode = 404;
res.end('Not found');
});
server.listen(PORT, () => {
console.log('\n\x1b[32m🎉 Party Mix запущен!\x1b[0m');
console.log(`\nОткройте в браузере: \x1b[36mhttp://localhost:${PORT}\x1b[0m\n`);
});

11
start.bat Normal file
View File

@@ -0,0 +1,11 @@
@echo off
echo Запуск Party Mix...
where node >nul 2>nul
IF %ERRORLEVEL% NEQ 0 (
echo Node.js не найден. Скачайте с https://nodejs.org
pause
exit
)
start "" http://localhost:3000
node server.js
pause

6
start.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
echo "Запуск Party Mix..."
node server.js &
sleep 1
open http://localhost:3000 2>/dev/null || xdg-open http://localhost:3000 2>/dev/null
wait