BurmaldaMusic-ClientApp-for.../burmalda-music/src/app.js
2026-04-26 15:18:06 +05:00

775 lines
34 KiB
JavaScript
Raw 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.

/* ── Burmalda Music — Renderer ──────────────────────────────────────────── */
'use strict';
const API = 'https://nodeshift.space/api';
const CDN = 'https://nodeshift.space';
/* ── State ──────────────────────────────────────────────────────────────── */
let token = null;
let me = null;
let queue = [];
let qi = -1;
let repeatMode = 0; // 0=off 1=all 2=one
let shuffleOn = false;
let likedSet = new Set();
let localTracks = [];
let searching = null;
const aud = document.getElementById('aud');
/* ── Titlebar ─────────────────────────────────────────────────────────────── */
document.getElementById('tb-min').addEventListener('click', () => window.electron.minimize());
document.getElementById('tb-max').addEventListener('click', () => window.electron.maximize());
document.getElementById('tb-close').addEventListener('click', () => window.electron.close());
window.electron.onWindowState(s => {
document.getElementById('tb-max').title = s === 'maximized' ? 'Восстановить' : 'Развернуть';
});
/* ── Auth helpers ─────────────────────────────────────────────────────────── */
function showForm(which) {
document.getElementById('form-login').style.display = which === 'login' ? '' : 'none';
document.getElementById('form-reg').style.display = which === 'reg' ? '' : 'none';
document.getElementById('login-err').textContent = '';
document.getElementById('reg-err').textContent = '';
}
async function initAuth() {
token = await window.electron.storeGet('token');
if (token) {
const ok = await fetchMe();
if (ok) { showApp(); return; }
token = null;
await window.electron.storeDelete('token');
}
document.getElementById('auth-screen').style.display = '';
document.getElementById('app').style.display = 'none';
}
async function fetchMe() {
try {
const r = await apiFetch('GET', '/music/profile/me');
if (!r.ok) return false;
me = await r.json();
return true;
} catch { return false; }
}
function showApp() {
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('app').style.display = '';
afterLogin();
}
/* Login submit */
document.getElementById('login-btn').addEventListener('click', async () => {
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-err');
btn.disabled = true; btn.textContent = 'Входим…'; err.textContent = '';
try {
const body = new URLSearchParams({
username: document.getElementById('login-email').value.trim(),
password: document.getElementById('login-pass').value,
});
const r = await fetch(`${API}/auth/token`, { method: 'POST', body });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || 'Неверный логин или пароль'); }
const d = await r.json();
token = d.access_token;
await window.electron.storeSet('token', token);
if (!await fetchMe()) throw new Error('Не удалось загрузить профиль');
showApp();
} catch(e) { err.textContent = e.message; }
finally { btn.disabled = false; btn.textContent = 'Войти'; }
});
/* Register submit */
document.getElementById('reg-btn').addEventListener('click', async () => {
const btn = document.getElementById('reg-btn');
const err = document.getElementById('reg-err');
btn.disabled = true; btn.textContent = 'Создаём…'; err.textContent = '';
try {
const email = document.getElementById('reg-email').value.trim();
const pass = document.getElementById('reg-pass').value;
const username = email.split('@')[0].replace(/[^a-z0-9_]/gi, '');
const r = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, username, password: pass }),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || 'Ошибка регистрации'); }
/* auto-login */
const r2 = await fetch(`${API}/auth/token`, { method: 'POST', body: new URLSearchParams({ username: email, password: pass }) });
if (!r2.ok) throw new Error('Аккаунт создан — теперь войдите.');
const d2 = await r2.json();
token = d2.access_token;
await window.electron.storeSet('token', token);
await fetchMe();
showApp();
} catch(e) { err.textContent = e.message; }
finally { btn.disabled = false; btn.textContent = 'Создать аккаунт'; }
});
function logout() {
token = null; me = null;
window.electron.storeDelete('token');
document.getElementById('auth-screen').style.display = '';
document.getElementById('app').style.display = 'none';
showForm('login');
}
/* ── API fetch ────────────────────────────────────────────────────────────── */
function apiFetch(method, path, body) {
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (body) headers['Content-Type'] = 'application/json';
return fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
}
/* ── After login ──────────────────────────────────────────────────────────── */
async function afterLogin() {
renderGreeting();
renderUserChip();
await loadLiked();
showPage('home', document.querySelector('.nav-item[data-page="home"]'));
}
/* ── Greeting ─────────────────────────────────────────────────────────────── */
function renderGreeting() {
const h = new Date().getHours();
const greet = h < 6 ? 'Доброй ночи' : h < 12 ? 'Доброе утро' : h < 18 ? 'Добрый день' : 'Добрый вечер';
const el = document.getElementById('greeting');
if (el) el.textContent = greet + (me?.username ? `, ${me.username}` : '') + '!';
loadHomeTracks();
}
/* ── User chip ────────────────────────────────────────────────────────────── */
function renderUserChip() {
if (!me) return;
const av = document.getElementById('user-av');
const nm = document.getElementById('user-name');
const pl = document.getElementById('user-plan');
if (av) {
if (me.avatar_url) {
av.innerHTML = `<img src="${CDN + me.avatar_url}" alt="">`;
} else {
av.textContent = (me.username || me.email || '?')[0].toUpperCase();
}
}
if (nm) nm.textContent = me.username || me.email || '';
if (pl) pl.textContent = me.plan || 'Free';
}
/* ── Navigation ──────────────────────────────────────────────────────────── */
function showPage(page, el) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const p = document.getElementById('page-' + page);
if (p) p.classList.add('active');
if (el) el.classList.add('active');
if (page === 'home') loadHomeTracks();
if (page === 'library') loadLibrary();
if (page === 'liked') renderLiked();
if (page === 'search') document.getElementById('search-input').focus();
if (page === 'local') renderLocalList();
}
/* ── Home ────────────────────────────────────────────────────────────────── */
async function loadHomeTracks() {
const el = document.getElementById('list-home');
if (!el) return;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiFetch('GET', '/music/tracks?limit=40');
if (!r.ok) throw new Error();
const d = await r.json();
const tracks = d.tracks || d || [];
renderTrackList(el, tracks);
} catch { el.innerHTML = empty('Не удалось загрузить треки'); }
}
/* ── Library ─────────────────────────────────────────────────────────────── */
async function loadLibrary() {
const el = document.getElementById('list-lib');
if (!el) return;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiFetch('GET', `/music/profile/${me?.username}/tracks`);
const d = r.ok ? await r.json() : { tracks: [] };
const tracks = d.tracks || [];
const cnt = document.getElementById('cnt-lib');
if (cnt) cnt.textContent = tracks.length ? `${tracks.length} треков` : '';
renderTrackList(el, tracks);
} catch { el.innerHTML = empty('Ошибка загрузки'); }
}
/* ── Liked ───────────────────────────────────────────────────────────────── */
async function loadLiked() {
try {
const r = await apiFetch('GET', '/music/liked');
if (!r.ok) return;
const d = await r.json();
likedSet = new Set((d.tracks || []).map(t => t.id));
} catch {}
}
async function renderLiked() {
const el = document.getElementById('list-liked');
if (!el) return;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiFetch('GET', '/music/liked');
const d = r.ok ? await r.json() : { tracks: [] };
const tracks = d.tracks || [];
const cnt = document.getElementById('cnt-liked');
if (cnt) cnt.textContent = tracks.length ? `${tracks.length} треков` : '';
renderTrackList(el, tracks);
} catch { el.innerHTML = empty('Ошибка загрузки'); }
}
/* ── Search ──────────────────────────────────────────────────────────────── */
let searchT = null;
function onSearch(q) {
clearTimeout(searchT);
const el = document.getElementById('list-search');
const em = document.getElementById('search-empty');
if (!q.trim()) { if (el) el.innerHTML = ''; if (em) em.style.display = 'none'; return; }
searchT = setTimeout(() => doSearch(q.trim()), 300);
}
async function doSearch(q) {
const el = document.getElementById('list-search');
const em = document.getElementById('search-empty');
if (el) el.innerHTML = '<div class="spinner"></div>';
if (em) em.style.display = 'none';
try {
const r = await apiFetch('GET', `/music/tracks?q=${encodeURIComponent(q)}&limit=50`);
if (!r.ok) throw new Error();
const d = await r.json();
const tracks = d.tracks || d || [];
if (el) renderTrackList(el, tracks);
if (em) em.style.display = !tracks.length ? 'block' : 'none';
} catch { if (el) el.innerHTML = empty('Ошибка поиска'); }
}
/* ── Local files ──────────────────────────────────────────────────────────── */
async function addLocalFiles() {
const files = await window.electron.openFiles();
ingestLocal(files);
}
async function addLocalFolder() {
const files = await window.electron.openFolder();
ingestLocal(files);
}
function ingestLocal(files) {
if (!files?.length) return;
const mapped = files.map(f => ({ ...f, title: f.name, artist: '', _local: true, id: null }));
localTracks = [...localTracks, ...mapped];
renderLocalList();
toast(`Добавлено ${files.length} файлов`);
}
function renderLocalList() {
const el = document.getElementById('list-local');
const dz = document.getElementById('drop-zone');
if (!el) return;
if (!localTracks.length) {
el.style.display = 'none';
if (dz) dz.style.display = '';
return;
}
if (dz) dz.style.display = 'none';
el.style.display = '';
renderTrackList(el, localTracks);
}
/* ── Drop zone ────────────────────────────────────────────────────────────── */
const dz = document.getElementById('drop-zone');
if (dz) {
dz.addEventListener('click', addLocalFiles);
document.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
document.addEventListener('dragleave', e => { if (!e.relatedTarget) dz.classList.remove('over'); });
document.addEventListener('drop', async e => {
e.preventDefault(); dz.classList.remove('over');
const files = Array.from(e.dataTransfer.files)
.filter(f => /\.(mp3|flac|wav|aac|ogg|m4a|opus|wv|ape)$/i.test(f.name))
.map(f => ({ path: f.path, name: f.name.replace(/\.[^.]+$/, ''), ext: f.name.split('.').pop().toUpperCase(), size: f.size }));
ingestLocal(files);
});
}
/* ── Render track list ───────────────────────────────────────────────────── */
function renderTrackList(container, tracks) {
container.innerHTML = '';
if (!tracks.length) { container.innerHTML = empty('Нет треков'); return; }
tracks.forEach((t, i) => container.appendChild(makeRow(t, tracks, i)));
}
function makeRow(t, arr, idx) {
const row = document.createElement('div');
row.className = 'track-row' + (queue[qi]?.id === t.id && t.id ? ' active' : '');
if (t.id) row.dataset.id = t.id;
const isLocal = !!t._local;
const coverSrc = t.cover_url ? (isLocal ? t.cover_url : CDN + t.cover_url) : null;
const isPlaying = queue[qi] && (t.id ? t.id === queue[qi].id : t === queue[qi]);
row.innerHTML = `
${coverSrc
? `<img class="t-cover" src="${coverSrc}" alt="" loading="lazy">`
: `<div class="t-cover-ph">${svgMusic(18)}</div>`}
<div class="t-info">
<div class="t-title">${esc(t.title || t.name || 'Без названия')}</div>
<div class="t-artist">
${t.artist
? `<span class="alink" data-artist="${esc(t.artist)}">${esc(t.artist)}</span>`
: '<span style="color:var(--text3)">Неизвестен</span>'}
</div>
</div>
<div class="t-right">
${isPlaying ? `<div class="eq"><span></span><span></span><span></span></div>` : ''}
${!isLocal ? `<span class="t-dur">${fmtDur(t.duration)}</span>` : `<span class="t-badge">${esc(t.ext||'FILE')}</span>`}
<div class="t-btns">
${!isLocal && t.id ? `<button class="t-btn${likedSet.has(t.id) ? ' liked' : ''}" data-act="like" title="Нравится">${svgHeart()}</button>` : ''}
${!isLocal && t.audio_url ? `<button class="t-btn" data-act="dl" title="Скачать">${svgDl()}</button>` : ''}
</div>
</div>
`;
row.addEventListener('click', e => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'like') { toggleLikeTrack(t, row); return; }
if (act === 'dl') { downloadTrack(t); return; }
const al = e.target.closest('.alink');
if (al) { e.stopPropagation(); openArtist(al.dataset.artist); return; }
playFrom(arr, idx);
});
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtx(e, t, arr, idx); });
return row;
}
/* ── Artist page ─────────────────────────────────────────────────────────── */
async function openArtist(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
let pg = document.getElementById('page-artist');
if (!pg) {
pg = document.createElement('section');
pg.className = 'page'; pg.id = 'page-artist';
pg.innerHTML = `
<div class="page-header">
<button class="pill-btn" onclick="backFromArtist()" style="gap:6px">
${svgChevronL()} Назад
</button>
<h2 class="page-title" id="artist-pg-name"></h2>
</div>
<div id="artist-pg-list"></div>
`;
document.querySelector('.main').appendChild(pg);
}
pg.classList.add('active');
document.getElementById('artist-pg-name').textContent = name;
const list = document.getElementById('artist-pg-list');
list.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiFetch('GET', `/music/tracks?artist=${encodeURIComponent(name)}&limit=100`);
const d = r.ok ? await r.json() : { tracks: [] };
const tracks = d.tracks || d || [];
renderTrackList(list, tracks);
} catch { list.innerHTML = empty('Ошибка загрузки'); }
}
function backFromArtist() {
document.getElementById('page-artist')?.classList.remove('active');
document.getElementById('page-home')?.classList.add('active');
document.querySelector('.nav-item[data-page="home"]')?.classList.add('active');
}
/* ── Playback ────────────────────────────────────────────────────────────── */
async function playFrom(arr, idx) {
queue = arr.slice();
qi = idx;
await playItem();
}
async function playItem() {
if (qi < 0 || qi >= queue.length) return;
const t = queue[qi];
let src;
if (t._local) {
/* local files — play directly, no caching */
src = await window.electron.readFileUrl(t.path);
} else {
if (!t.audio_url) { nextTrack(); return; }
/* CDN tracks — route through encrypted cache */
try {
src = await window.electron.cacheStream({
url: CDN + t.audio_url,
trackId: String(t.id || t.audio_url.replace(/\W+/g, '')),
userToken: token || '',
});
} catch {
/* fallback to direct streaming if cache fails */
src = CDN + t.audio_url;
}
}
aud.src = src;
aud.load();
try { await aud.play(); } catch {}
updatePlayerBar();
updateAllRows();
updateAmbiance();
/* register play stat */
if (t.id) apiFetch('POST', `/music/tracks/${t.id}/play`).catch(() => {});
}
function prevTrack() {
if (aud.currentTime > 3) { aud.currentTime = 0; return; }
if (shuffleOn) { qi = randIdx(); playItem(); return; }
if (qi > 0) { qi--; playItem(); }
else if (repeatMode > 0) { qi = queue.length - 1; playItem(); }
}
function nextTrack() {
if (shuffleOn) { qi = randIdx(); playItem(); return; }
if (qi < queue.length - 1) { qi++; playItem(); return; }
if (repeatMode === 1) { qi = 0; playItem(); return; }
/* stop */
updatePlayBtn();
}
function randIdx() {
if (queue.length <= 1) return 0;
let n; do { n = Math.floor(Math.random() * queue.length); } while (n === qi);
return n;
}
aud.addEventListener('ended', () => {
if (repeatMode === 2) { aud.currentTime = 0; aud.play(); return; }
nextTrack();
});
function togglePlay() {
if (!queue[qi]) return;
aud.paused ? aud.play() : aud.pause();
}
function toggleShuffle() {
shuffleOn = !shuffleOn;
document.getElementById('btn-shuf')?.classList.toggle('on', shuffleOn);
document.getElementById('fs-shuf')?.classList.toggle('on', shuffleOn);
}
function cycleRepeat() {
repeatMode = (repeatMode + 1) % 3;
const labels = ['Повтор выкл', 'Повторять всё', 'Повторять трек'];
document.getElementById('btn-rep')?.classList.toggle('on', repeatMode > 0);
document.getElementById('fs-rep')?.classList.toggle('on', repeatMode > 0);
if (document.getElementById('btn-rep')) document.getElementById('btn-rep').title = labels[repeatMode];
if (document.getElementById('fs-rep')) document.getElementById('fs-rep').title = labels[repeatMode];
}
aud.addEventListener('play', updatePlayBtn);
aud.addEventListener('pause', updatePlayBtn);
/* ── Player bar UI ────────────────────────────────────────────────────────── */
function updatePlayerBar() {
const t = queue[qi];
if (!t) return;
const isLocal = !!t._local;
const coverSrc = t.cover_url ? (isLocal ? t.cover_url : CDN + t.cover_url) : null;
const pc = document.getElementById('player-cover');
const fc = document.getElementById('fs-cover');
const fb = document.getElementById('fs-bg');
if (pc) pc.innerHTML = coverSrc
? `<img src="${coverSrc}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`
: `<div class="cover-placeholder">${svgMusic(20)}</div>`;
if (fc) fc.innerHTML = coverSrc
? `<img src="${coverSrc}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`
: `<div class="cover-placeholder">${svgMusic(40)}</div>`;
if (fb && coverSrc) fb.style.backgroundImage = `url(${coverSrc})`;
else if (fb) fb.style.backgroundImage = '';
document.getElementById('player-title').textContent = t.title || t.name || 'Без названия';
document.getElementById('player-artist').textContent = t.artist || '—';
document.getElementById('fs-title').textContent = t.title || t.name || '—';
document.getElementById('fs-artist').textContent = t.artist || '';
document.getElementById('fs-album')?.setAttribute('textContent', t.album || '');
updateLikeBtn();
updatePlayBtn();
/* vinyl spin */
const vinyl = document.getElementById('fs-vinyl');
if (vinyl) vinyl.classList.toggle('spinning', !aud.paused);
}
function updatePlayBtn() {
const playing = !aud.paused;
const vinyl = document.getElementById('fs-vinyl');
if (vinyl) vinyl.classList.toggle('spinning', playing);
[
{ play: document.getElementById('ico-play'), pause: document.getElementById('ico-pause') },
{ play: document.getElementById('fs-ico-play'), pause: document.getElementById('fs-ico-pause') },
].forEach(({ play, pause }) => {
if (!play || !pause) return;
play.style.display = playing ? 'none' : '';
pause.style.display = playing ? '' : 'none';
});
}
function updateLikeBtn() {
const t = queue[qi];
const isLiked = t?.id && likedSet.has(t.id);
document.getElementById('player-like')?.classList.toggle('liked', !!isLiked);
}
function updateAllRows() {
const cur = queue[qi];
document.querySelectorAll('.track-row').forEach(row => {
row.classList.toggle('active', !!cur && (cur.id ? row.dataset.id == cur.id : false));
});
}
function updateAmbiance() {
const t = queue[qi];
const ba1 = document.getElementById('ba1');
const ba2 = document.getElementById('ba2');
if (!ba1 || !ba2 || !t?.cover_url) { return; }
const src = t._local ? t.cover_url : CDN + t.cover_url;
ba1.style.backgroundImage = `url(${src})`;
ba2.style.backgroundImage = `url(${src})`;
ba1.style.opacity = '0.18';
ba2.style.opacity = '0.12';
}
/* ── Progress / time ─────────────────────────────────────────────────────── */
aud.addEventListener('timeupdate', () => {
if (!aud.duration) return;
const pct = aud.currentTime / aud.duration;
const v = Math.round(pct * 1000);
const pf = document.getElementById('prog-fill');
if (pf) pf.style.width = (pct * 100) + '%';
const ps = document.getElementById('prog-seek');
if (ps && !ps.matches(':active')) ps.value = v;
const ff = document.getElementById('fs-fill');
if (ff) ff.style.width = (pct * 100) + '%';
const fs = document.querySelector('.fs-seek');
if (fs && !fs.matches(':active')) fs.value = v;
document.getElementById('t-cur').textContent = fmtDur(aud.currentTime);
document.getElementById('fs-cur').textContent = fmtDur(aud.currentTime);
if (aud.duration) {
const rem = aud.duration - aud.currentTime;
document.getElementById('t-dur').textContent = fmtDur(aud.duration);
document.getElementById('fs-dur').textContent = fmtDur(rem);
}
});
aud.addEventListener('loadedmetadata', () => {
document.getElementById('t-dur').textContent = fmtDur(aud.duration);
document.getElementById('fs-dur').textContent = fmtDur(aud.duration);
});
/* buffer progress */
aud.addEventListener('progress', () => {
if (!aud.duration || !aud.buffered.length) return;
const pct = aud.buffered.end(aud.buffered.length - 1) / aud.duration;
const pb = document.getElementById('prog-buf');
if (pb) pb.style.width = (pct * 100) + '%';
const fb2 = document.getElementById('fs-buf');
if (fb2) fb2.style.width = (pct * 100) + '%';
});
function seekTo(val) {
if (!aud.duration) return;
aud.currentTime = (val / 1000) * aud.duration;
}
function setVolume(val) {
aud.volume = val / 100;
}
/* ── Fullscreen ───────────────────────────────────────────────────────────── */
function toggleFullscreen() {
document.getElementById('fullscreen').classList.toggle('open');
}
/* ── Like ──────────────────────────────────────────────────────────────────── */
async function toggleLikeTrack(t, row) {
if (!t.id) return;
const was = likedSet.has(t.id);
try {
if (was) { await apiFetch('DELETE', `/music/liked/${t.id}`); likedSet.delete(t.id); }
else { await apiFetch('POST', `/music/liked/${t.id}`); likedSet.add(t.id); }
const btn = row.querySelector('[data-act="like"]');
if (btn) btn.classList.toggle('liked', likedSet.has(t.id));
updateLikeBtn();
} catch {}
}
function toggleLike() {
const t = queue[qi];
if (!t) return;
const row = document.querySelector(`.track-row[data-id="${t.id}"]`) || { querySelector: () => null };
toggleLikeTrack(t, row);
}
/* ── Download ─────────────────────────────────────────────────────────────── */
async function downloadTrack(t) {
if (!t.audio_url) { toast('Нет аудиофайла'); return; }
toast(`Скачиваем ${t.title || 'трек'}…`);
try {
const ext = t.audio_url.split('.').pop().split('?')[0] || 'mp3';
const safe = s => String(s).replace(/[\\/:*?"<>|]/g, '');
const filename = `${safe(t.artist || 'Unknown')} - ${safe(t.title || 'track')}.${ext}`;
const dest = await window.electron.downloadTrack({ url: CDN + t.audio_url, filename });
toast(`Сохранено: ${dest.replace(/.*[\\/]/, '')}`);
} catch(e) { toast('Ошибка: ' + e); }
}
/* ── Queue panel ─────────────────────────────────────────────────────────── */
function toggleQueue() {
const qp = document.getElementById('queue-panel');
const open = qp.classList.toggle('open');
if (open) renderQueuePanel();
}
function renderQueuePanel() {
const list = document.getElementById('qp-list');
if (!list) return;
list.innerHTML = '';
queue.forEach((t, i) => {
const el = document.createElement('div');
el.className = 'track-row' + (i === qi ? ' active' : '');
el.style.fontSize = '12px';
const name = (t.title || t.name || 'Без названия');
el.innerHTML = `<div class="t-cover-ph">${svgMusic(14)}</div>
<div class="t-info"><div class="t-title">${esc(name)}</div><div class="t-artist" style="font-size:11px">${esc(t.artist||'')}</div></div>`;
el.addEventListener('click', () => { qi = i; playItem(); });
list.appendChild(el);
});
}
/* ── Context menu ─────────────────────────────────────────────────────────── */
let ctxEl = null;
function showCtx(e, t, arr, idx) {
if (ctxEl) ctxEl.remove();
const m = document.createElement('div');
m.className = 'ctx';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
const items = [
{ label: 'Воспроизвести', fn: () => playFrom(arr, idx) },
{ label: 'Добавить в очередь', fn: () => { queue.push(t); toast('Добавлено в очередь'); } },
...(t.id ? [{ sep: true }, { label: likedSet.has(t.id) ? 'Убрать из любимых' : 'В любимые', fn: () => toggleLikeTrack(t, m) }] : []),
...(t.audio_url ? [{ label: 'Скачать', fn: () => downloadTrack(t) }] : []),
...(t.artist ? [{ sep: true }, { label: 'Все треки: ' + t.artist, fn: () => openArtist(t.artist) }] : []),
];
items.forEach(item => {
if (item.sep) { const s = document.createElement('div'); s.className = 'ctx-sep'; m.appendChild(s); return; }
const el = document.createElement('div');
el.className = 'ctx-item';
el.textContent = item.label;
el.addEventListener('click', () => { item.fn(); m.remove(); ctxEl = null; });
m.appendChild(el);
});
document.body.appendChild(m);
ctxEl = m;
/* keep in viewport */
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = (e.clientX - r.width) + 'px';
if (r.bottom > window.innerHeight) m.style.top = (e.clientY - r.height) + 'px';
setTimeout(() => document.addEventListener('click', () => { m.remove(); ctxEl = null; }, { once: true }), 0);
}
/* ── Media keys ───────────────────────────────────────────────────────────── */
window.electron.onMediaKey(key => {
if (key === 'playpause') togglePlay();
if (key === 'next') nextTrack();
if (key === 'prev') prevTrack();
});
/* ── Keyboard ─────────────────────────────────────────────────────────────── */
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
if (e.code === 'ArrowRight') { e.preventDefault(); aud.currentTime += 5; }
if (e.code === 'ArrowLeft') { e.preventDefault(); aud.currentTime -= 5; }
if (e.code === 'ArrowUp') { e.preventDefault(); aud.volume = Math.min(1, aud.volume + .05); syncVolSlider(); }
if (e.code === 'ArrowDown') { e.preventDefault(); aud.volume = Math.max(0, aud.volume - .05); syncVolSlider(); }
if (e.code === 'Escape') { document.getElementById('fullscreen')?.classList.remove('open'); }
});
function syncVolSlider() {
const vs = document.getElementById('vol-slider');
if (vs) vs.value = Math.round(aud.volume * 100);
}
/* ── Toast ────────────────────────────────────────────────────────────────── */
function toast(msg, dur = 3500) {
let wrap = document.getElementById('toast-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = 'toast-wrap';
document.body.appendChild(wrap);
}
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => el.remove(), dur);
}
/* ── Helpers ──────────────────────────────────────────────────────────────── */
function fmtDur(s) {
if (!s || isNaN(s)) return '--:--';
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return m + ':' + String(sec).padStart(2, '0');
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function empty(txt) {
return `<div class="empty">${svgMusic(40)}<p>${esc(txt)}</p></div>`;
}
/* ── SVG icons ────────────────────────────────────────────────────────────── */
function svgMusic(s=22){return`<svg width="${s}" height="${s}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path d="M9 19V6l12-3v13M9 19a3 3 0 01-6 0 3 3 0 016 0zm12 0a3 3 0 01-6 0 3 3 0 016 0z"/></svg>`;}
function svgHeart() {return`<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>`;}
function svgDl() {return`<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`;}
function svgChevronL() {return`<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>`;}
/* ── Init ──────────────────────────────────────────────────────────────────── */
initAuth();