Add files via upload
This commit is contained in:
parent
74b2f56dae
commit
f9524c2691
10 changed files with 6753 additions and 0 deletions
254
burmalda-music/main.js
Normal file
254
burmalda-music/main.js
Normal 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
4509
burmalda-music/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
59
burmalda-music/package.json
Normal file
59
burmalda-music/package.json
Normal 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
49
burmalda-music/preload.js
Normal 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
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();
|
||||||
BIN
burmalda-music/src/assets/icon.ico
Normal file
BIN
burmalda-music/src/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
burmalda-music/src/assets/icon.png
Normal file
BIN
burmalda-music/src/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
burmalda-music/src/assets/icon512.png
Normal file
BIN
burmalda-music/src/assets/icon512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
343
burmalda-music/src/index.html
Normal file
343
burmalda-music/src/index.html
Normal 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>
|
||||||
764
burmalda-music/src/styles.css
Normal file
764
burmalda-music/src/styles.css
Normal 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; }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue