Add files via upload

This commit is contained in:
Savsis 2026-04-26 15:18:06 +05:00 • committed by GitHub
parent 74b2f56dae
commit f9524c2691
10 changed files with 6753 additions and 0 deletions

254
burmalda-music/main.js Normal file
View file

@ -0,0 +1,254 @@
const {
app, BrowserWindow, ipcMain, globalShortcut, shell, dialog,
protocol, session, net,
} = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const { autoUpdater } = require('electron-updater');
// ── DRM cache scheme — must be registered before app.whenReady() ──────────────
protocol.registerSchemesAsPrivileged([{
scheme: 'bmcache',
privileges: {
standard: true,
secure: true,
bypassCSP: true,
corsEnabled: true,
stream: true,
supportFetchAPI: true,
},
}]);
// ── Persistent store ──────────────────────────────────────────────────────────
let Store;
async function getStore() {
if (!Store) {
const mod = await import('electron-store');
Store = mod.default;
}
return new Store({ name: 'burmalda' });
}
// ── DRM cache helpers ─────────────────────────────────────────────────────────
const CACHE_DIR = path.join(os.homedir(), '.burmalda-cache');
async function getCacheKey() {
const store = await getStore();
let hex = store.get('cacheKey');
if (!hex) {
hex = crypto.randomBytes(32).toString('hex');
store.set('cacheKey', hex);
}
return Buffer.from(hex, 'hex');
}
function cacheFilePath(trackId) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
return path.join(CACHE_DIR, `${trackId}.bmc`);
}
async function readCached(trackId) {
const fp = cacheFilePath(trackId);
if (!fs.existsSync(fp)) return null;
try {
const enc = fs.readFileSync(fp);
const key = await getCacheKey();
const iv = enc.slice(0, 16);
const body = enc.slice(16);
const dec = crypto.createDecipheriv('aes-256-cbc', key, iv);
const plain = Buffer.concat([dec.update(body), dec.final()]);
const mimeLen = plain.readUInt32BE(0);
const mime = plain.slice(4, 4 + mimeLen).toString('utf8');
const audio = plain.slice(4 + mimeLen);
return { audio, mime };
} catch {
try { fs.unlinkSync(fp); } catch {}
return null;
}
}
const _downloading = new Set();
async function downloadAndCache(url, trackId) {
if (_downloading.has(trackId)) return;
const fp = cacheFilePath(trackId);
if (fs.existsSync(fp)) return;
_downloading.add(trackId);
try {
const resp = await net.fetch(url);
if (!resp.ok) return;
const mime = resp.headers.get('content-type') || 'audio/mpeg';
const audio = Buffer.from(await resp.arrayBuffer());
const key = await getCacheKey();
const iv = crypto.randomBytes(16);
const mimeBuf = Buffer.from(mime, 'utf8');
const lenBuf = Buffer.allocUnsafe(4);
lenBuf.writeUInt32BE(mimeBuf.length, 0);
const plain = Buffer.concat([lenBuf, mimeBuf, audio]);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const enc = Buffer.concat([iv, cipher.update(plain), cipher.final()]);
fs.writeFileSync(fp, enc);
} catch {
// network errors silently ignored
} finally {
_downloading.delete(trackId);
}
}
// Extract a stable track-id from CDN URL (filename without extension)
function trackIdFromUrl(url) {
return path.basename(new URL(url).pathname).replace(/\.[^.]+$/, '');
}
// ── DRM cache interception ────────────────────────────────────────────────────
function setupDrmCache() {
// 1. Serve decrypted audio via bmcache:// scheme
protocol.handle('bmcache', async (request) => {
const trackId = decodeURIComponent(new URL(request.url).pathname.replace('/audio/', ''));
const cached = await readCached(trackId);
if (cached) {
return new Response(cached.audio, {
status: 200,
headers: {
'content-type': cached.mime,
'content-length': String(cached.audio.length),
'accept-ranges': 'bytes',
'access-control-allow-origin': '*',
},
});
}
return new Response('not cached', { status: 404 });
});
// 2. Intercept CDN audio-only requests → redirect to local cache when available
// Cover images (.jpg) and other non-audio assets pass through untouched.
const AUDIO_EXTS = new Set(['.mp3', '.m4a', '.ogg', '.wav', '.flac', '.aac', '.opus', '.webm', '.weba']);
const CDN_PATTERNS = [
'https://cdn.nodeshift.space/music/*',
'https://cdnl.nodeshift.space/music/*',
];
session.defaultSession.webRequest.onBeforeRequest({ urls: CDN_PATTERNS }, (details, cb) => {
const url = details.url;
const ext = path.extname(new URL(url).pathname).toLowerCase();
if (!AUDIO_EXTS.has(ext)) { cb({}); return; }
const trackId = trackIdFromUrl(url);
const fp = cacheFilePath(trackId);
if (fs.existsSync(fp)) {
cb({ redirectURL: `bmcache://audio/${encodeURIComponent(trackId)}` });
} else {
downloadAndCache(url, trackId); // cache in background
cb({}); // pass through for now
}
});
}
// ── IPC: cache management (exposed to renderer via contextBridge) ─────────────
ipcMain.handle('cache-size', async () => {
try {
const files = fs.readdirSync(CACHE_DIR).filter(f => f.endsWith('.bmc'));
const bytes = files.reduce((s, f) => s + fs.statSync(path.join(CACHE_DIR, f)).size, 0);
return { count: files.length, bytes };
} catch { return { count: 0, bytes: 0 }; }
});
ipcMain.handle('cache-clear', async () => {
try {
const files = fs.readdirSync(CACHE_DIR).filter(f => f.endsWith('.bmc'));
files.forEach(f => { try { fs.unlinkSync(path.join(CACHE_DIR, f)); } catch {} });
return files.length;
} catch { return 0; }
});
// ── Main window ───────────────────────────────────────────────────────────────
const MUSIC_URL = 'https://music.nodeshift.space';
let mainWindow = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280, height: 800, minWidth: 900, minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
titleBarOverlay: { color: '#0a0a0a', symbolColor: '#ffffff', height: 38 },
backgroundColor: '#0a0a0a',
show: false,
icon: path.join(__dirname, 'src/assets/icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
mainWindow.loadURL(MUSIC_URL);
mainWindow.once('ready-to-show', () => { mainWindow.show(); mainWindow.focus(); });
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (_event, url) => {
if (!url.includes('nodeshift.space') && !url.startsWith('https://accounts.google.com')) {
_event.preventDefault();
shell.openExternal(url);
}
});
}
// ── Auto-update ───────────────────────────────────────────────────────────────
function setupAutoUpdater() {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Обновление готово',
message: 'Новая версия Burmalda Music скачана. Перезапустить сейчас?',
buttons: ['Перезапустить', 'Позже'], defaultId: 0,
}).then(({ response }) => { if (response === 0) autoUpdater.quitAndInstall(); });
});
setTimeout(() => autoUpdater.checkForUpdates().catch(() => {}), 3000);
setInterval(() => autoUpdater.checkForUpdates().catch(() => {}), 4 * 60 * 60 * 1000);
}
// ── App lifecycle ─────────────────────────────────────────────────────────────
app.whenReady().then(() => {
setupDrmCache();
createWindow();
setupMediaKeys();
setupAutoUpdater();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('will-quit', () => globalShortcut.unregisterAll());
// ── Window controls ───────────────────────────────────────────────────────────
ipcMain.on('win-minimize', () => mainWindow?.minimize());
ipcMain.on('win-maximize', () => { if (mainWindow?.isMaximized()) mainWindow.unmaximize(); else mainWindow?.maximize(); });
ipcMain.on('win-close', () => mainWindow?.close());
ipcMain.on('win-fullscreen', () => mainWindow?.setFullScreen(!mainWindow.isFullScreen()));
// ── Persistent store IPC ──────────────────────────────────────────────────────
ipcMain.handle('store-get', async (_, key) => { const s = await getStore(); return s.get(key); });
ipcMain.handle('store-set', async (_, key, value) => { const s = await getStore(); s.set(key, value); });
ipcMain.handle('store-delete', async (_, key) => { const s = await getStore(); s.delete(key); });
ipcMain.handle('get-version', () => app.getVersion());
// ── Media keys ────────────────────────────────────────────────────────────────
function execSafe(js) { mainWindow?.webContents.executeJavaScript(js).catch(() => {}); }
function setupMediaKeys() {
globalShortcut.register('MediaPlayPause', () => execSafe('typeof togglePlay==="function"&&togglePlay()'));
globalShortcut.register('MediaNextTrack', () => execSafe('typeof next==="function"&&next()'));
globalShortcut.register('MediaPreviousTrack', () => execSafe('typeof prev==="function"&&prev()'));
}

4509
burmalda-music/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
{
"name": "burmalda-music",
"version": "1.0.1",
"description": "Burmalda Music — desktop client for NodeShift Music",
"main": "main.js",
"author": "NodeShift",
"license": "MIT",
"scripts": {
"start": "electron .",
"dev": "electron . --dev",
"build": "electron-builder --win --x64",
"build:all": "electron-builder --win --mac --linux"
},
"build": {
"appId": "space.nodeshift.burmalda",
"productName": "Burmalda Music",
"copyright": "© 2026 NodeShift",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"src/assets/**/*"
],
"publish": [
{
"provider": "generic",
"url": "https://nodeshift.space/download/"
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "src/assets/icon.ico"
},
"nsis": {
"oneClick": true,
"perMachine": false,
"allowToChangeInstallationDirectory": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
},
"devDependencies": {
"electron": "^31.0.0",
"electron-builder": "^24.13.3"
},
"dependencies": {
"electron-store": "^8.1.0",
"electron-updater": "^6.8.3"
}
}

49
burmalda-music/preload.js Normal file
View file

@ -0,0 +1,49 @@
const { contextBridge, ipcRenderer } = require('electron');
// ── Inject styles for Electron titlebar overlay compatibility ─────────────────
// Adds top padding so the 38px system buttons don't cover the site's nav bar.
// Also makes the nav bar draggable so the user can move the window.
window.addEventListener('DOMContentLoaded', () => {
const style = document.createElement('style');
style.textContent = `
/* Reserve space for Windows titlebar overlay (38px) */
body { padding-top: 38px !important; }
/* Make the top nav draggable (window drag region) */
.topnav {
-webkit-app-region: drag !important;
margin-top: -38px !important;
padding-top: 38px !important;
height: calc(var(--nav, 52px) + 38px) !important;
/* Reserve right side for min/max/close buttons (≈138px on Windows) */
padding-right: 148px !important;
box-sizing: border-box !important;
}
/* All interactive elements inside the nav must NOT be drag targets */
.topnav *, .topnav button, .topnav a, .topnav input,
.topnav [onclick], .topnav [tabindex] {
-webkit-app-region: no-drag !important;
}
/* Hide Windows download prompts — already in the app */
.sb-dl-app, .dl-app-banner, #dl-app-banner,
[class*="dl-app"], [id*="dl-app"] {
display: none !important;
}
/* Push fullscreen player close button below the 38px native titlebar */
.fs-overlay .fs-top {
padding-top: 46px !important;
}
`;
document.head.appendChild(style);
});
// ── Minimal Electron API (future extensibility) ───────────────────────────────
contextBridge.exposeInMainWorld('electronApp', {
isElectron: true,
getVersion: () => ipcRenderer.invoke('get-version'),
storeGet: (k) => ipcRenderer.invoke('store-get', k),
storeSet: (k, v) => ipcRenderer.invoke('store-set', k, v),
storeDelete: (k) => ipcRenderer.invoke('store-delete', k),
// DRM cache management (can be called from the site's settings page)
cacheSize: () => ipcRenderer.invoke('cache-size'),
cacheClear: () => ipcRenderer.invoke('cache-clear'),
});

775
burmalda-music/src/app.js Normal file
View 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,'&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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' https://nodeshift.space https://*.nodeshift.space https://cdnl.nodeshift.space https://cdn.nodeshift.space; media-src 'self' blob: file: https://*.nodeshift.space; img-src 'self' data: blob: https://*.nodeshift.space; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';">
<title>Burmalda Music</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- ── CUSTOM TITLE BAR ─────────────────────────────────────────── -->
<div class="titlebar" id="titlebar">
<div class="titlebar-logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#1db954" stroke-width="2" stroke-linecap="round"/>
<circle cx="6" cy="18" r="3" fill="#1db954"/>
<circle cx="18" cy="16" r="3" fill="#1db954"/>
</svg>
<span class="titlebar-name">Burmalda Music <span class="titlebar-by">by NodeShift</span></span>
</div>
<div class="titlebar-drag"></div>
<div class="titlebar-controls">
<button class="tb-btn" id="tb-min" title="Свернуть">
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
</button>
<button class="tb-btn" id="tb-max" title="Развернуть">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><rect x=".5" y=".5" width="9" height="9" stroke="currentColor"/></svg>
</button>
<button class="tb-btn close" id="tb-close" title="Закрыть">
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
</div>
<!-- ── AUTH SCREEN ──────────────────────────────────────────────── -->
<div class="auth-screen" id="auth-screen">
<div class="auth-bg">
<div class="auth-blob b1"></div>
<div class="auth-blob b2"></div>
<div class="auth-blob b3"></div>
</div>
<div class="auth-card" id="auth-card">
<div class="auth-logo">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#1db954" stroke-width="2" stroke-linecap="round"/>
<circle cx="6" cy="18" r="3" fill="#1db954"/>
<circle cx="18" cy="16" r="3" fill="#1db954"/>
</svg>
<h1 class="auth-title">Burmalda Music</h1>
<p class="auth-sub">by NodeShift</p>
</div>
<!-- Login form -->
<div id="form-login">
<div class="auth-field">
<label>Email</label>
<input type="email" id="login-email" placeholder="you@example.com" autocomplete="email">
</div>
<div class="auth-field">
<label>Пароль</label>
<input type="password" id="login-pass" placeholder="••••••••" autocomplete="current-password">
</div>
<div class="auth-err" id="login-err"></div>
<button class="auth-btn" id="login-btn">Войти</button>
<p class="auth-switch">Нет аккаунта? <span onclick="showForm('reg')">Зарегистрироваться</span></p>
</div>
<!-- Register form -->
<div id="form-reg" style="display:none">
<div class="auth-field">
<label>Email</label>
<input type="email" id="reg-email" placeholder="you@example.com" autocomplete="email">
</div>
<div class="auth-field">
<label>Пароль</label>
<input type="password" id="reg-pass" placeholder="Минимум 8 символов" autocomplete="new-password">
</div>
<div class="auth-err" id="reg-err"></div>
<button class="auth-btn" id="reg-btn">Создать аккаунт</button>
<p class="auth-switch">Уже есть аккаунт? <span onclick="showForm('login')">Войти</span></p>
</div>
</div>
</div>
<!-- ── MAIN APP ──────────────────────────────────────────────────── -->
<div class="app" id="app" style="display:none">
<!-- SIDEBAR -->
<aside class="sidebar" id="sidebar">
<nav class="nav-links">
<a class="nav-item active" data-page="home" onclick="showPage('home',this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
Главная
</a>
<a class="nav-item" data-page="library" onclick="showPage('library',this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
Библиотека
</a>
<a class="nav-item" data-page="local" onclick="showPage('local',this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Локальные
</a>
<a class="nav-item" data-page="liked" onclick="showPage('liked',this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
Любимые
</a>
<a class="nav-item" data-page="search" onclick="showPage('search',this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Поиск
</a>
</nav>
<div class="sidebar-bottom">
<div class="user-chip" id="user-chip">
<div class="user-av" id="user-av"></div>
<div class="user-info">
<div class="user-name" id="user-name">—</div>
<div class="user-plan" id="user-plan">Free</div>
</div>
<button class="logout-btn" onclick="logout()" title="Выйти">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16,17 21,12 16,7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main" id="main">
<!-- BG ambiance -->
<div class="bg-ambiance" id="bg-ambiance">
<div class="ba-blob" id="ba1"></div>
<div class="ba-blob" id="ba2"></div>
</div>
<!-- HOME -->
<section class="page active" id="page-home">
<div class="page-header">
<h2 class="page-title">Главная</h2>
<span class="page-subtitle" id="greeting"></span>
</div>
<div class="section-label">Недавнее</div>
<div class="tracklist" id="list-home"></div>
</section>
<!-- LIBRARY -->
<section class="page" id="page-library">
<div class="page-header">
<h2 class="page-title">Библиотека</h2>
<span class="track-count" id="cnt-lib"></span>
</div>
<div class="tracklist" id="list-lib"></div>
</section>
<!-- LOCAL FILES -->
<section class="page" id="page-local">
<div class="page-header">
<h2 class="page-title">Локальные файлы</h2>
<div class="local-actions">
<button class="pill-btn" onclick="addLocalFiles()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Добавить файлы
</button>
<button class="pill-btn" onclick="addLocalFolder()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Открыть папку
</button>
</div>
</div>
<div class="drop-zone" id="drop-zone">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.2)" stroke-width="1.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>Перетащите аудиофайлы сюда</p>
<span>MP3, FLAC, WAV, AAC, OGG и другие</span>
</div>
<div class="tracklist" id="list-local" style="display:none"></div>
</section>
<!-- LIKED -->
<section class="page" id="page-liked">
<div class="page-header">
<h2 class="page-title">Любимые треки</h2>
<span class="track-count" id="cnt-liked"></span>
</div>
<div class="tracklist" id="list-liked"></div>
</section>
<!-- SEARCH -->
<section class="page" id="page-search">
<div class="page-header">
<h2 class="page-title">Поиск</h2>
</div>
<div class="search-bar-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="search-input" placeholder="Исполнитель, трек, альбом…" oninput="onSearch(this.value)">
</div>
<div class="tracklist" id="list-search"></div>
<div class="search-empty" id="search-empty" style="display:none">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.15)" stroke-width="1.2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<p>Ничего не найдено</p>
</div>
</section>
</main>
<!-- ── PLAYER BAR ─────────────────────────────────────────────── -->
<footer class="player" id="player">
<!-- progress / buffer line -->
<div class="player-progress-line">
<div class="progress-buf" id="prog-buf"></div>
<div class="progress-fill" id="prog-fill"></div>
<input type="range" class="progress-seek" id="prog-seek" min="0" max="1000" value="0" oninput="seekTo(this.value)">
</div>
<!-- left: cover + info -->
<div class="player-left" onclick="toggleFullscreen()">
<div class="player-cover" id="player-cover">
<div class="cover-placeholder">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.3)" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
</div>
<div class="player-meta">
<div class="player-title" id="player-title">Не воспроизводится</div>
<div class="player-artist" id="player-artist">—</div>
</div>
<button class="like-btn" id="player-like" onclick="event.stopPropagation();toggleLike()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
</button>
</div>
<!-- center: controls -->
<div class="player-center">
<div class="player-controls">
<button class="pctl" id="btn-shuf" onclick="toggleShuffle()" title="Перемешать">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16,3 21,3 21,8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21,16 21,21 16,21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
</button>
<button class="pctl" onclick="prevTrack()" title="Назад">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button class="pctl play" id="btn-play" onclick="togglePlay()">
<svg id="ico-play" width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg id="ico-pause" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button class="pctl" onclick="nextTrack()" title="Вперёд">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/></svg>
</button>
<button class="pctl" id="btn-rep" onclick="cycleRepeat()" title="Повтор">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</button>
</div>
<div class="player-time-row">
<span class="ptime" id="t-cur">0:00</span>
<span class="ptime" id="t-dur">0:00</span>
</div>
</div>
<!-- right: volume + queue -->
<div class="player-right">
<button class="pctl" onclick="toggleQueue()" title="Очередь">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<div class="vol-row">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11,5 6,9 2,9 2,15 6,15 11,19 11,5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
<input type="range" class="vol-slider" id="vol-slider" min="0" max="100" value="80" oninput="setVolume(this.value)">
</div>
</div>
<!-- audio element -->
<audio id="aud" preload="none" crossorigin="anonymous"></audio>
</footer>
<!-- ── FULLSCREEN PLAYER ───────────────────────────────────────── -->
<div class="fullscreen" id="fullscreen">
<div class="fs-bg" id="fs-bg"></div>
<div class="fs-bg-blur"></div>
<button class="fs-close" onclick="toggleFullscreen()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6,9 12,15 18,9"/></svg>
</button>
<div class="fs-body">
<div class="fs-cover-wrap">
<div class="fs-cover" id="fs-cover">
<div class="cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.2)" stroke-width="1.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
</div>
<!-- vinyl ring animation -->
<div class="fs-vinyl" id="fs-vinyl">
<div class="vinyl-outer"></div>
<div class="vinyl-inner"></div>
</div>
</div>
<div class="fs-meta">
<div class="fs-title" id="fs-title">—</div>
<div class="fs-artist" id="fs-artist">—</div>
<div class="fs-album" id="fs-album"></div>
</div>
<div class="fs-progress-wrap">
<div class="fs-progress-track" id="fs-track">
<div class="fs-progress-buf" id="fs-buf"></div>
<div class="fs-progress-fill" id="fs-fill"></div>
<div class="fs-progress-dot"></div>
</div>
<input type="range" class="fs-seek" min="0" max="1000" value="0" oninput="seekTo(this.value);document.getElementById('prog-seek').value=this.value">
<div class="fs-times"><span id="fs-cur">0:00</span><span id="fs-dur">0:00</span></div>
</div>
<div class="fs-controls">
<button class="fs-ctl" id="fs-shuf" onclick="toggleShuffle()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16,3 21,3 21,8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21,16 21,21 16,21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
</button>
<button class="fs-ctl" onclick="prevTrack()">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button class="fs-ctl play" id="fs-btn-play" onclick="togglePlay()">
<svg id="fs-ico-play" width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg id="fs-ico-pause" width="28" height="28" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button class="fs-ctl" onclick="nextTrack()">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/></svg>
</button>
<button class="fs-ctl" id="fs-rep" onclick="cycleRepeat()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</button>
</div>
</div>
</div>
<!-- QUEUE PANEL -->
<div class="queue-panel" id="queue-panel">
<div class="qp-header">
<span>Очередь</span>
<button onclick="toggleQueue()">✕</button>
</div>
<div class="qp-list" id="qp-list"></div>
</div>
</div><!-- /.app -->
<audio id="aud-pre" preload="none" crossorigin="anonymous" style="display:none"></audio>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,764 @@
/* ── Reset & Variables ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a;
--bg1: #111111;
--bg2: #181818;
--bg3: #222222;
--bg4: #2c2c2c;
--border: #252525;
--border2: #333333;
--text: #f0f0f0;
--text2: #aaaaaa;
--text3: #555555;
--accent: #6c63ff;
--accent2: #8b84ff;
--accent-bg: rgba(108,99,255,.12);
--green: #1db954;
--red: #e84040;
--titlebar: 38px;
--sidebar: 210px;
--player-h: 76px;
--radius: 10px;
--r2: 6px;
--trans: .18s ease;
--shadow: 0 8px 40px rgba(0,0,0,.6);
}
html, body {
height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
/* ── Title bar ───────────────────────────────────────────────────────────── */
.titlebar {
position: fixed; top: 0; left: 0; right: 0; height: var(--titlebar);
background: rgba(10,10,10,.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
-webkit-app-region: drag;
z-index: 1000; user-select: none;
padding: 0 8px 0 14px;
}
.titlebar-logo { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.titlebar-logo svg { flex-shrink: 0; }
.titlebar-name {
font-size: 12.5px; font-weight: 600; color: var(--text);
letter-spacing: .02em; white-space: nowrap;
}
.titlebar-by { color: var(--text3); font-weight: 400; }
.titlebar-drag { flex: 1; height: 100%; }
.titlebar-controls {
display: flex; align-items: center; gap: 2px;
-webkit-app-region: no-drag;
}
.tb-btn {
width: 34px; height: 28px; background: transparent; border: none;
color: var(--text3); cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: background var(--trans), color var(--trans);
-webkit-app-region: no-drag;
}
.tb-btn:hover { background: var(--bg3); color: var(--text); }
.tb-btn.close:hover { background: var(--red); color: #fff; }
/* ── Auth screen ─────────────────────────────────────────────────────────── */
.auth-screen {
position: fixed; inset: 0;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
padding-top: var(--titlebar);
overflow: hidden;
}
/* Animated background blobs */
.auth-bg {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.auth-blob {
position: absolute; border-radius: 50%;
filter: blur(80px); opacity: .35; animation: blobMove 10s ease-in-out infinite;
}
.auth-blob.b1 {
width: 400px; height: 400px;
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
top: -10%; left: -10%;
animation-delay: 0s;
}
.auth-blob.b2 {
width: 320px; height: 320px;
background: radial-gradient(circle, #ff6bab 0%, transparent 70%);
bottom: 0; right: 5%;
animation-delay: -3.5s;
}
.auth-blob.b3 {
width: 280px; height: 280px;
background: radial-gradient(circle, var(--green) 0%, transparent 70%);
top: 40%; left: 40%;
animation-delay: -7s;
}
@keyframes blobMove {
0%, 100% { transform: translate(0,0) scale(1); }
33% { transform: translate(30px,-20px) scale(1.05); }
66% { transform: translate(-20px,30px) scale(.95); }
}
.auth-card {
position: relative; z-index: 1;
width: 340px;
background: rgba(20,20,20,.8);
backdrop-filter: blur(24px);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 28px;
box-shadow: var(--shadow);
animation: cardIn .4s cubic-bezier(.2,.8,.3,1);
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px) scale(.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.auth-logo {
text-align: center; margin-bottom: 24px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.auth-title { font-size: 20px; font-weight: 700; }
.auth-sub { font-size: 12px; color: var(--text3); letter-spacing: .04em; }
.auth-field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 12px; }
.auth-field label {
font-size: 11.5px; color: var(--text2); font-weight: 500;
letter-spacing: .04em; text-transform: uppercase;
}
.auth-field input {
background: var(--bg3); border: 1px solid var(--border2);
border-radius: var(--r2); color: var(--text); font-size: 13.5px;
height: 42px; padding: 0 14px; outline: none;
transition: border-color var(--trans), box-shadow var(--trans);
}
.auth-field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(108,99,255,.2);
}
.auth-field input::placeholder { color: var(--text3); }
.auth-err { font-size: 12px; color: var(--red); min-height: 18px; margin-bottom: 4px; }
.auth-btn {
width: 100%; height: 44px;
background: var(--accent); border: none; border-radius: var(--r2);
color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
transition: background var(--trans), transform var(--trans), box-shadow var(--trans);
margin-bottom: 14px;
}
.auth-btn:hover { background: var(--accent2); box-shadow: 0 4px 20px rgba(108,99,255,.4); }
.auth-btn:active { transform: scale(.98); }
.auth-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
.auth-switch { font-size: 12px; color: var(--text3); text-align: center; }
.auth-switch span { color: var(--accent2); cursor: pointer; }
.auth-switch span:hover { text-decoration: underline; }
/* ── App layout ──────────────────────────────────────────────────────────── */
.app {
position: fixed; inset: 0;
padding-top: var(--titlebar);
display: flex; flex-direction: column;
}
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar); background: var(--bg1);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
flex-shrink: 0; overflow-y: auto;
position: absolute; left: 0;
top: var(--titlebar); bottom: var(--player-h);
}
.nav-links { padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; border-radius: var(--r2);
cursor: pointer; color: var(--text2);
font-size: 13px; font-weight: 500;
text-decoration: none;
transition: background var(--trans), color var(--trans);
user-select: none;
}
.nav-item:hover { background: var(--bg3); color: var(--text); }
.nav-item.active { background: var(--accent-bg); color: var(--accent2); }
.nav-item svg { flex-shrink: 0; }
.sidebar-bottom {
margin-top: auto; border-top: 1px solid var(--border);
padding: 10px 8px;
}
.user-chip {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: var(--r2);
cursor: default;
}
.user-av {
width: 30px; height: 30px; border-radius: 50%;
background: var(--accent-bg); border: 1px solid var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: var(--accent2); flex-shrink: 0;
overflow: hidden;
}
.user-av img { width: 100%; height: 100%; object-fit: cover; }
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-plan { font-size: 11px; color: var(--text3); }
.logout-btn {
background: transparent; border: none; color: var(--text3);
cursor: pointer; width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: color var(--trans), background var(--trans);
flex-shrink: 0;
}
.logout-btn:hover { color: var(--red); background: rgba(232,64,64,.1); }
/* ── Main content ────────────────────────────────────────────────────────── */
.main {
position: absolute; left: var(--sidebar); right: 0;
top: var(--titlebar); bottom: var(--player-h);
overflow-y: auto; overflow-x: hidden;
}
/* Background ambiance */
.bg-ambiance {
position: fixed; left: var(--sidebar); right: 0;
top: var(--titlebar); bottom: var(--player-h);
pointer-events: none; overflow: hidden; z-index: 0;
}
.ba-blob {
position: absolute; border-radius: 50%;
filter: blur(120px); opacity: 0; transition: all 2s ease;
pointer-events: none;
}
#ba1 { width: 500px; height: 500px; top: -20%; left: -10%; }
#ba2 { width: 400px; height: 400px; bottom: -20%; right: -10%; }
/* ── Pages ───────────────────────────────────────────────────────────────── */
.page { display: none; padding: 28px 28px 16px; position: relative; z-index: 1; animation: fadeIn .2s ease; }
.page.active { display: block; }
.page-header {
display: flex; align-items: baseline; gap: 14px; margin-bottom: 20px;
}
.page-title { font-size: 22px; font-weight: 800; letter-spacing: -.02em; }
.page-subtitle, .track-count {
font-size: 13px; color: var(--text3);
}
.section-label {
font-size: 11px; font-weight: 600; color: var(--text3);
letter-spacing: .08em; text-transform: uppercase;
margin-bottom: 10px;
}
/* ── Track list ──────────────────────────────────────────────────────────── */
.tracklist { display: flex; flex-direction: column; gap: 2px; }
.track-row {
display: grid;
grid-template-columns: 44px 1fr auto;
align-items: center; gap: 10px;
padding: 6px 8px; border-radius: var(--r2);
cursor: pointer;
transition: background var(--trans);
animation: fadeIn .15s ease;
position: relative;
}
.track-row:hover { background: var(--bg2); }
.track-row.active { background: var(--accent-bg); }
.t-cover {
width: 44px; height: 44px; border-radius: 6px; object-fit: cover;
background: var(--bg3); flex-shrink: 0; display: block;
}
.t-cover-ph {
width: 44px; height: 44px; border-radius: 6px; background: var(--bg3);
display: flex; align-items: center; justify-content: center; color: var(--text3);
}
.t-info { min-width: 0; }
.t-title {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.track-row.active .t-title { color: var(--accent2); }
.t-artist {
font-size: 12px; color: var(--text2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.t-artist .alink { cursor: pointer; transition: color var(--trans); }
.t-artist .alink:hover { color: var(--text); text-decoration: underline; }
.t-right { display: flex; align-items: center; gap: 6px; }
.t-dur { font-size: 12px; color: var(--text3); white-space: nowrap; }
.t-badge {
font-size: 9px; font-weight: 700; padding: 2px 5px; border-radius: 3px;
background: var(--bg3); color: var(--text3); letter-spacing: .04em;
}
.t-btns { display: flex; gap: 2px; opacity: 0; transition: opacity var(--trans); }
.track-row:hover .t-btns { opacity: 1; }
.t-btn {
width: 28px; height: 28px; background: transparent; border: none;
color: var(--text3); cursor: pointer; border-radius: var(--r2);
display: flex; align-items: center; justify-content: center;
transition: background var(--trans), color var(--trans);
}
.t-btn:hover { background: var(--bg4); color: var(--text); }
.t-btn.liked { color: var(--red); opacity: 1; }
/* Playing animation bars */
.eq { display: flex; align-items: flex-end; gap: 2px; height: 14px; width: 12px; }
.eq span {
width: 3px; background: var(--accent2); border-radius: 1px;
animation: eq .55s ease-in-out infinite alternate;
}
.eq span:nth-child(2) { animation-delay: .12s; }
.eq span:nth-child(3) { animation-delay: .24s; }
@keyframes eq { from { height: 3px; } to { height: 14px; } }
/* ── Empty/loading ───────────────────────────────────────────────────────── */
.empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 60px; color: var(--text3); gap: 12px;
}
.empty svg { opacity: .3; }
.empty p { font-size: 14px; }
.spinner {
width: 24px; height: 24px;
border: 2px solid var(--border2); border-top-color: var(--accent);
border-radius: 50%; animation: spin .7s linear infinite;
margin: 40px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Local files ─────────────────────────────────────────────────────────── */
.local-actions { display: flex; gap: 8px; margin-bottom: 16px; }
.pill-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 99px;
background: var(--bg2); border: 1px solid var(--border2);
color: var(--text2); font-size: 12.5px; font-weight: 500;
cursor: pointer; transition: all var(--trans);
}
.pill-btn:hover { background: var(--bg4); color: var(--text); }
.drop-zone {
border: 2px dashed var(--border2); border-radius: var(--radius);
padding: 48px 24px; text-align: center; cursor: pointer;
transition: all var(--trans); margin-bottom: 20px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.drop-zone:hover, .drop-zone.over {
border-color: var(--accent); background: var(--accent-bg);
}
.drop-zone svg { opacity: .4; }
.drop-zone p { font-size: 14px; color: var(--text2); }
.drop-zone span { font-size: 12px; color: var(--text3); }
/* ── Search bar ──────────────────────────────────────────────────────────── */
.search-bar-wrap {
display: flex; align-items: center; gap: 10px;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 99px; padding: 0 16px; height: 44px; margin-bottom: 20px;
transition: border-color var(--trans), box-shadow var(--trans);
}
.search-bar-wrap:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(108,99,255,.15);
}
.search-bar-wrap svg { color: var(--text3); flex-shrink: 0; }
#search-input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text); font-size: 14px;
}
#search-input::placeholder { color: var(--text3); }
.search-empty { text-align: center; padding: 60px; color: var(--text3); }
.search-empty svg { margin-bottom: 12px; display: block; margin-left: auto; margin-right: auto; }
/* ── Player bar ──────────────────────────────────────────────────────────── */
.player {
position: fixed; left: 0; right: 0; bottom: 0;
height: var(--player-h);
background: rgba(10,10,10,.95); backdrop-filter: blur(20px);
border-top: 1px solid var(--border);
display: flex; align-items: center; padding: 0 16px; gap: 12px;
z-index: 100;
}
/* thin progress line on top of player */
.player-progress-line {
position: absolute; top: 0; left: 0; right: 0; height: 3px;
cursor: pointer; overflow: visible;
}
.progress-buf {
position: absolute; left: 0; top: 0; height: 100%;
background: var(--bg4); border-radius: 99px; pointer-events: none;
}
.progress-fill {
position: absolute; left: 0; top: 0; height: 100%;
background: var(--accent); border-radius: 99px; pointer-events: none;
transition: width .1s linear;
}
.progress-seek {
position: absolute; inset: -4px 0;
opacity: 0; cursor: pointer; width: 100%;
-webkit-appearance: none; appearance: none;
}
.progress-seek::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px; height: 12px;
border-radius: 50%; background: #fff;
cursor: pointer;
}
/* player left: cover + info */
.player-left {
display: flex; align-items: center; gap: 10px;
min-width: 0; flex: 0 0 230px; cursor: pointer;
}
.player-cover {
width: 50px; height: 50px; border-radius: 7px; flex-shrink: 0;
overflow: hidden; background: var(--bg3);
transition: transform var(--trans);
}
.player-cover:hover { transform: scale(1.04); }
.player-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-placeholder {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.player-meta { min-width: 0; }
.player-title {
font-size: 13px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.player-artist {
font-size: 12px; color: var(--text2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.like-btn {
background: transparent; border: none; color: var(--text3);
cursor: pointer; width: 30px; height: 30px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: color var(--trans), background var(--trans);
flex-shrink: 0; margin-left: 2px;
}
.like-btn:hover { color: var(--red); background: rgba(232,64,64,.1); }
.like-btn.liked { color: var(--red); }
/* player center: controls + time */
.player-center {
flex: 1; display: flex; flex-direction: column;
align-items: center; gap: 4px;
}
.player-controls { display: flex; align-items: center; gap: 4px; }
.pctl {
width: 34px; height: 34px; background: transparent; border: none;
color: var(--text2); cursor: pointer; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: color var(--trans), background var(--trans), transform var(--trans);
}
.pctl:hover { color: var(--text); background: var(--bg3); }
.pctl.on { color: var(--accent2); }
.pctl.play {
width: 40px; height: 40px;
background: var(--text); color: var(--bg); border-radius: 50%;
}
.pctl.play:hover { background: #fff; transform: scale(1.06); box-shadow: 0 2px 14px rgba(255,255,255,.2); }
.player-time-row {
display: flex; align-items: center; justify-content: space-between;
width: 100%; max-width: 420px; padding: 0 2px;
}
.ptime { font-size: 11px; color: var(--text3); }
/* player right: volume */
.player-right { display: flex; align-items: center; gap: 8px; flex: 0 0 160px; justify-content: flex-end; }
.vol-row { display: flex; align-items: center; gap: 6px; }
.vol-row svg { color: var(--text3); flex-shrink: 0; }
.vol-slider {
width: 80px; -webkit-appearance: none; appearance: none;
height: 4px; border-radius: 99px; background: var(--bg4); cursor: pointer;
outline: none;
}
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 12px; height: 12px;
border-radius: 50%; background: var(--text); cursor: pointer;
}
.vol-slider:hover::-webkit-slider-thumb { background: #fff; }
/* ── Fullscreen player ───────────────────────────────────────────────────── */
.fullscreen {
position: fixed; inset: var(--titlebar) 0 0;
background: var(--bg); z-index: 200;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 32px;
transform: translateY(100%); transition: transform .35s cubic-bezier(.2,.8,.3,1);
}
.fullscreen.open { transform: translateY(0); }
.fs-bg {
position: absolute; inset: -5%;
background-size: cover; background-position: center;
filter: blur(60px) brightness(.35) saturate(1.5);
transform: scale(1.1); pointer-events: none; transition: background-image .5s ease;
}
.fs-bg-blur {
position: absolute; inset: 0;
background: rgba(0,0,0,.55); pointer-events: none;
}
.fs-close {
position: absolute; top: 16px; right: 20px; z-index: 1;
background: rgba(255,255,255,.1); border: none; color: var(--text);
width: 38px; height: 38px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: background var(--trans);
}
.fs-close:hover { background: rgba(255,255,255,.2); }
.fs-body {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: 0;
width: 100%; max-width: 380px;
}
.fs-cover-wrap { position: relative; margin-bottom: 28px; }
.fs-cover {
width: 230px; height: 230px; border-radius: 12px; overflow: hidden;
background: var(--bg3); box-shadow: 0 20px 60px rgba(0,0,0,.6);
position: relative; z-index: 1;
}
.fs-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Vinyl ring */
.fs-vinyl {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
z-index: 0; animation: vinylSpin 3s linear infinite;
animation-play-state: paused;
}
.fs-vinyl.spinning { animation-play-state: running; }
.vinyl-outer {
width: 280px; height: 280px; border-radius: 50%;
background: conic-gradient(from 0deg, #1a1a1a, #111, #1a1a1a, #0a0a0a, #1a1a1a);
border: 1px solid rgba(255,255,255,.06);
display: flex; align-items: center; justify-content: center;
}
.vinyl-inner {
width: 40px; height: 40px; border-radius: 50%;
background: radial-gradient(circle, #2a2a2a 0%, #111 60%);
border: 3px solid rgba(255,255,255,.1);
}
@keyframes vinylSpin { to { transform: translate(-50%,-50%) rotate(360deg); } }
.fs-meta { text-align: center; margin-bottom: 24px; width: 100%; }
.fs-title {
font-size: 22px; font-weight: 800; letter-spacing: -.02em;
margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.fs-artist {
font-size: 14px; color: var(--text2); cursor: pointer;
}
.fs-artist:hover { color: var(--text); text-decoration: underline; }
.fs-album { font-size: 12px; color: var(--text3); margin-top: 2px; }
.fs-progress-wrap { width: 100%; margin-bottom: 16px; }
.fs-progress-track {
width: 100%; height: 4px; background: rgba(255,255,255,.12);
border-radius: 99px; position: relative; cursor: pointer;
overflow: visible;
}
.fs-progress-buf, .fs-progress-fill {
position: absolute; left: 0; top: 0; height: 100%;
border-radius: 99px; pointer-events: none;
}
.fs-progress-buf { background: rgba(255,255,255,.2); }
.fs-progress-fill { background: #fff; transition: width .1s linear; }
.fs-progress-dot {
position: absolute; right: 0; top: 50%; transform: translate(50%,-50%);
width: 12px; height: 12px; border-radius: 50%; background: #fff;
pointer-events: none; opacity: 0; transition: opacity var(--trans);
}
.fs-progress-track:hover .fs-progress-dot { opacity: 1; }
.fs-seek {
position: absolute; inset: -8px 0; opacity: 0; cursor: pointer;
width: 100%; -webkit-appearance: none; appearance: none;
}
.fs-seek::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; cursor: pointer; }
.fs-times {
display: flex; justify-content: space-between; margin-top: 6px;
font-size: 11px; color: rgba(255,255,255,.5);
}
.fs-controls { display: flex; align-items: center; gap: 8px; }
.fs-ctl {
width: 48px; height: 48px; background: rgba(255,255,255,.08);
border: none; color: rgba(255,255,255,.7); cursor: pointer;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
transition: all var(--trans);
}
.fs-ctl:hover { background: rgba(255,255,255,.15); color: #fff; }
.fs-ctl.on { color: var(--accent2); }
.fs-ctl.play { width: 60px; height: 60px; background: #fff; color: var(--bg); }
.fs-ctl.play:hover { background: #eee; transform: scale(1.06); box-shadow: 0 2px 20px rgba(255,255,255,.3); }
/* ── Queue panel ─────────────────────────────────────────────────────────── */
.queue-panel {
position: fixed; right: 0;
top: var(--titlebar); bottom: var(--player-h);
width: 300px; background: var(--bg1);
border-left: 1px solid var(--border);
transform: translateX(100%); transition: transform .25s ease;
z-index: 50; display: flex; flex-direction: column;
}
.queue-panel.open { transform: translateX(0); }
.qp-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border);
font-weight: 600; font-size: 14px;
}
.qp-header button { background: transparent; border: none; color: var(--text3); cursor: pointer; font-size: 16px; }
.qp-header button:hover { color: var(--text); }
.qp-list { flex: 1; overflow-y: auto; padding: 6px 8px; }
/* ── Context menu ─────────────────────────────────────────────────────────── */
.ctx {
position: fixed; background: var(--bg2); border: 1px solid var(--border2);
border-radius: var(--radius); padding: 4px; min-width: 160px;
box-shadow: var(--shadow); z-index: 5000; animation: fadeIn .1s ease;
}
.ctx-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: var(--r2);
font-size: 13px; cursor: pointer; color: var(--text2);
transition: all var(--trans);
}
.ctx-item:hover { background: var(--bg4); color: var(--text); }
.ctx-item.red:hover { background: rgba(232,64,64,.12); color: var(--red); }
.ctx-sep { height: 1px; background: var(--border); margin: 3px 0; }
/* ── Toast ───────────────────────────────────────────────────────────────── */
#toast-wrap {
position: fixed; right: 14px; bottom: calc(var(--player-h) + 14px);
z-index: 9999; display: flex; flex-direction: column; gap: 7px; pointer-events: none;
}
.toast {
background: var(--bg3); border: 1px solid var(--border2);
border-radius: var(--r2); padding: 10px 14px;
font-size: 13px; color: var(--text2); pointer-events: all;
box-shadow: var(--shadow); animation: toastIn .2s ease;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
/* ── Animations ──────────────────────────────────────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}