Add files via upload
This commit is contained in:
parent
74b2f56dae
commit
f9524c2691
10 changed files with 6753 additions and 0 deletions
775
burmalda-music/src/app.js
Normal file
775
burmalda-music/src/app.js
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
/* ── 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue