/* ── 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 = ``; } 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 = '
'; 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 = '
'; 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 = '
'; 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 = '
'; 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 ? `` : `
${svgMusic(18)}
`}
${esc(t.title || t.name || 'Без названия')}
${t.artist ? `${esc(t.artist)}` : 'Неизвестен'}
${isPlaying ? `
` : ''} ${!isLocal ? `${fmtDur(t.duration)}` : `${esc(t.ext||'FILE')}`}
${!isLocal && t.id ? `` : ''} ${!isLocal && t.audio_url ? `` : ''}
`; 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 = `
`; 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 = '
'; 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 ? `` : `
${svgMusic(20)}
`; if (fc) fc.innerHTML = coverSrc ? `` : `
${svgMusic(40)}
`; 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 = `
${svgMusic(14)}
${esc(name)}
${esc(t.artist||'')}
`; 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,'"'); } function empty(txt) { return `
${svgMusic(40)}

${esc(txt)}

`; } /* ── SVG icons ────────────────────────────────────────────────────────────── */ function svgMusic(s=22){return``;} function svgHeart() {return``;} function svgDl() {return``;} function svgChevronL() {return``;} /* ── Init ──────────────────────────────────────────────────────────────────── */ initAuth();