Files
party-mix-app/index.html

772 lines
43 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>