775 lines
34 KiB
JavaScript
775 lines
34 KiB
JavaScript
/* ── 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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();
|