634 lines
23 KiB
JavaScript
634 lines
23 KiB
JavaScript
import { GreyscaleAPI } from './api.js';
|
|
import { SimplePlayer } from './player.js';
|
|
import { DownloadManager } from './downloads.js';
|
|
import { LyricsManager } from './lyrics.js';
|
|
import { debounce, escapeHtml, formatDate, formatDuration, formatYear, getTrackArtists } from './utils.js';
|
|
|
|
const els = {
|
|
form: document.getElementById('search-form'),
|
|
input: document.getElementById('search-input'),
|
|
button: document.getElementById('search-button'),
|
|
status: document.getElementById('status-text'),
|
|
tabs: document.getElementById('search-tabs'),
|
|
pageContent: document.getElementById('page-content'),
|
|
backBtn: document.getElementById('back-btn'),
|
|
|
|
audio: document.getElementById('audio-player'),
|
|
playPauseBtn: document.getElementById('play-pause-btn'),
|
|
prevBtn: document.getElementById('prev-btn'),
|
|
nextBtn: document.getElementById('next-btn'),
|
|
lyricsBtn: document.getElementById('lyrics-btn'),
|
|
playerCover: document.getElementById('player-cover'),
|
|
playerTitle: document.getElementById('player-title'),
|
|
playerArtist: document.getElementById('player-artist'),
|
|
currentTime: document.getElementById('current-time'),
|
|
duration: document.getElementById('duration'),
|
|
progress: document.getElementById('progress'),
|
|
|
|
lyricsPanel: document.getElementById('lyrics-panel'),
|
|
lyricsCloseBtn: document.getElementById('lyrics-close-btn'),
|
|
lyricsContent: document.getElementById('lyrics-content'),
|
|
|
|
downloadsList: document.getElementById('downloads-list'),
|
|
downloadsClearBtn: document.getElementById('downloads-clear-btn'),
|
|
};
|
|
|
|
const api = new GreyscaleAPI();
|
|
const player = new SimplePlayer({
|
|
audio: els.audio,
|
|
progressEl: els.progress,
|
|
currentTimeEl: els.currentTime,
|
|
durationEl: els.duration,
|
|
});
|
|
const downloads = new DownloadManager(api);
|
|
const lyrics = new LyricsManager();
|
|
|
|
const state = {
|
|
query: '',
|
|
activeTab: 'tracks',
|
|
tracks: [],
|
|
albums: [],
|
|
artists: [],
|
|
currentLyrics: null,
|
|
currentLyricsTrackId: null,
|
|
currentQueue: [],
|
|
};
|
|
|
|
function setStatus(text, type = 'neutral') {
|
|
els.status.textContent = text;
|
|
els.status.dataset.type = type;
|
|
}
|
|
|
|
function setSearchLoading(loading) {
|
|
els.button.disabled = loading;
|
|
els.input.disabled = loading;
|
|
els.button.textContent = loading ? 'Searching...' : 'Search';
|
|
}
|
|
|
|
function setPlayButtonState() {
|
|
els.playPauseBtn.textContent = els.audio.paused ? '▶' : '❚❚';
|
|
}
|
|
|
|
function updatePlayerMeta(track) {
|
|
if (!track) {
|
|
els.playerTitle.textContent = 'No track selected';
|
|
els.playerArtist.textContent = '-';
|
|
els.playerCover.src = '';
|
|
return;
|
|
}
|
|
|
|
els.playerTitle.textContent = track.title || 'Unknown title';
|
|
els.playerArtist.textContent = track.artistName || getTrackArtists(track);
|
|
|
|
const cover = api.getCoverUrl(track.cover, 320);
|
|
els.playerCover.src = cover || '';
|
|
}
|
|
|
|
function normalizeTrack(track) {
|
|
if (!track) return null;
|
|
return {
|
|
...track,
|
|
artistName: track.artistName || getTrackArtists(track),
|
|
cover: track.cover || track.album?.cover || null,
|
|
};
|
|
}
|
|
|
|
function navigate(path) {
|
|
const finalPath = path.startsWith('/') ? path : `/${path}`;
|
|
window.location.hash = `#${finalPath}`;
|
|
}
|
|
|
|
function goBack() {
|
|
if (window.history.length > 1) {
|
|
window.history.back();
|
|
} else {
|
|
navigate('/home');
|
|
}
|
|
}
|
|
|
|
function parseRoute() {
|
|
const hash = window.location.hash || '#/home';
|
|
const clean = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
const route = clean || '/home';
|
|
const parts = route.split('/').filter(Boolean);
|
|
|
|
if (parts.length === 0) return { page: 'home', id: '' };
|
|
return { page: parts[0], id: decodeURIComponent(parts.slice(1).join('/')) };
|
|
}
|
|
|
|
function setActiveTab(tab) {
|
|
state.activeTab = tab;
|
|
els.tabs.querySelectorAll('.tab').forEach((btn) => {
|
|
btn.classList.toggle('active', btn.dataset.tab === tab);
|
|
});
|
|
}
|
|
|
|
function renderTrackList(items, context = {}) {
|
|
if (!items.length) return '<div class="empty">No tracks found.</div>';
|
|
|
|
return `
|
|
<ul class="entity-list">
|
|
${items
|
|
.map((track, index) => {
|
|
const t = normalizeTrack(track);
|
|
return `
|
|
<li class="entity-row">
|
|
<button class="cover-btn" data-action="open-track" data-id="${t.id}" title="Open track">
|
|
<img class="entity-cover" src="${escapeHtml(api.getCoverUrl(t.cover, 120))}" alt="" loading="lazy" />
|
|
</button>
|
|
<button class="entity-main" data-action="play-track" data-index="${index}" data-context="${escapeHtml(
|
|
context.queueKey || 'tracks'
|
|
)}">
|
|
<div class="entity-title">${escapeHtml(t.title || 'Unknown title')}</div>
|
|
<div class="entity-subtitle">${escapeHtml(t.artistName || 'Unknown artist')}${
|
|
t.albumTitle ? ` — ${escapeHtml(t.albumTitle)}` : ''
|
|
}</div>
|
|
</button>
|
|
<div class="entity-side">
|
|
<span class="entity-duration">${formatDuration(t.duration || 0)}</span>
|
|
<button class="mini-btn" data-action="download-track" data-id="${t.id}">⬇</button>
|
|
</div>
|
|
</li>
|
|
`;
|
|
})
|
|
.join('')}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
function renderAlbumGrid(items) {
|
|
if (!items.length) return '<div class="empty">No albums found.</div>';
|
|
|
|
return `
|
|
<div class="card-grid">
|
|
${items
|
|
.map((album) => {
|
|
const cover = api.getCoverUrl(album.cover, 320);
|
|
const artist = album.artist?.name || getTrackArtists(album);
|
|
return `
|
|
<button class="card" data-action="open-album" data-id="${album.id}">
|
|
<img class="card-cover" src="${escapeHtml(cover)}" alt="" loading="lazy" />
|
|
<div class="card-title">${escapeHtml(album.title || 'Unknown album')}</div>
|
|
<div class="card-subtitle">${escapeHtml(artist || 'Unknown artist')}</div>
|
|
<div class="card-meta">${escapeHtml(formatYear(album.releaseDate) || '')}</div>
|
|
</button>
|
|
`;
|
|
})
|
|
.join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderArtistGrid(items) {
|
|
if (!items.length) return '<div class="empty">No artists found.</div>';
|
|
|
|
return `
|
|
<div class="card-grid artist-grid">
|
|
${items
|
|
.map((artist) => {
|
|
const pic = api.getArtistPictureUrl(artist.picture, 320) || api.getCoverUrl(artist.picture, 320);
|
|
return `
|
|
<button class="card" data-action="open-artist" data-id="${artist.id}">
|
|
<img class="card-cover" src="${escapeHtml(pic)}" alt="" loading="lazy" />
|
|
<div class="card-title">${escapeHtml(artist.name || 'Unknown artist')}</div>
|
|
</button>
|
|
`;
|
|
})
|
|
.join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderHome() {
|
|
const { activeTab, tracks, albums, artists, query } = state;
|
|
|
|
let inner = '<div class="empty">Search to get started.</div>';
|
|
|
|
if (query) {
|
|
if (activeTab === 'tracks') inner = renderTrackList(tracks, { queueKey: 'search:tracks' });
|
|
if (activeTab === 'albums') inner = renderAlbumGrid(albums);
|
|
if (activeTab === 'artists') inner = renderArtistGrid(artists);
|
|
}
|
|
|
|
els.pageContent.innerHTML = `
|
|
<section class="page-section">
|
|
<h2>${query ? `Results for "${escapeHtml(query)}"` : 'Search Results'}</h2>
|
|
${inner}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
async function renderAlbumPage(id) {
|
|
els.pageContent.innerHTML = '<div class="loading">Loading album...</div>';
|
|
|
|
try {
|
|
const { album, tracks } = await api.getAlbum(id);
|
|
const normalizedTracks = tracks.map((t) => normalizeTrack(t));
|
|
state.currentQueue = normalizedTracks;
|
|
|
|
els.pageContent.innerHTML = `
|
|
<section class="detail-header">
|
|
<img class="detail-cover" src="${escapeHtml(api.getCoverUrl(album.cover, 640))}" alt="" />
|
|
<div class="detail-meta">
|
|
<h2>${escapeHtml(album.title || 'Unknown album')}</h2>
|
|
<button class="linkish" data-action="open-artist" data-id="${album.artist?.id || ''}">${escapeHtml(
|
|
album.artist?.name || 'Unknown artist'
|
|
)}</button>
|
|
<div class="muted">${escapeHtml(formatDate(album.releaseDate) || '')} • ${normalizedTracks.length} tracks</div>
|
|
<div class="detail-actions">
|
|
<button class="primary-btn" data-action="play-album">Play All</button>
|
|
<button class="mini-btn" data-action="download-album" data-id="${album.id}">Download All</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="page-section">
|
|
<h3>Tracks</h3>
|
|
${renderTrackList(normalizedTracks, { queueKey: `album:${album.id}` })}
|
|
</section>
|
|
`;
|
|
|
|
state.currentQueue = normalizedTracks;
|
|
} catch (error) {
|
|
els.pageContent.innerHTML = `<div class="error-block">Album load failed: ${escapeHtml(error.message || 'Unknown error')}</div>`;
|
|
}
|
|
}
|
|
|
|
async function renderArtistPage(id) {
|
|
els.pageContent.innerHTML = '<div class="loading">Loading artist...</div>';
|
|
|
|
try {
|
|
const artist = await api.getArtist(id);
|
|
const tracks = (artist.tracks || []).map((t) => normalizeTrack(t));
|
|
const albums = artist.albums || [];
|
|
const eps = artist.eps || [];
|
|
const pic = api.getArtistPictureUrl(artist.picture, 640) || api.getCoverUrl(artist.picture, 640);
|
|
|
|
els.pageContent.innerHTML = `
|
|
<section class="detail-header">
|
|
<img class="detail-cover" src="${escapeHtml(pic)}" alt="" />
|
|
<div class="detail-meta">
|
|
<h2>${escapeHtml(artist.name || 'Unknown artist')}</h2>
|
|
<div class="detail-actions">
|
|
<button class="primary-btn" data-action="play-top-tracks">Play Top Tracks</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="page-section">
|
|
<h3>Top Tracks</h3>
|
|
${renderTrackList(tracks, { queueKey: `artist:${artist.id}:tracks` })}
|
|
</section>
|
|
<section class="page-section">
|
|
<h3>Albums</h3>
|
|
${renderAlbumGrid(albums)}
|
|
</section>
|
|
<section class="page-section">
|
|
<h3>Singles & EPs</h3>
|
|
${renderAlbumGrid(eps)}
|
|
</section>
|
|
`;
|
|
|
|
state.currentQueue = tracks;
|
|
} catch (error) {
|
|
els.pageContent.innerHTML = `<div class="error-block">Artist load failed: ${escapeHtml(error.message || 'Unknown error')}</div>`;
|
|
}
|
|
}
|
|
|
|
async function renderTrackPage(id) {
|
|
els.pageContent.innerHTML = '<div class="loading">Loading track...</div>';
|
|
|
|
try {
|
|
const [trackRaw, recsRaw] = await Promise.all([api.getTrackMetadata(id), api.getTrackRecommendations(id)]);
|
|
const track = normalizeTrack(trackRaw);
|
|
const recs = (recsRaw || []).map((t) => normalizeTrack(t));
|
|
|
|
els.pageContent.innerHTML = `
|
|
<section class="detail-header">
|
|
<img class="detail-cover" src="${escapeHtml(api.getCoverUrl(track.cover, 640))}" alt="" />
|
|
<div class="detail-meta">
|
|
<h2>${escapeHtml(track.title || 'Unknown title')}</h2>
|
|
<button class="linkish" data-action="open-artist" data-id="${track.artist?.id || ''}">${escapeHtml(
|
|
track.artistName || 'Unknown artist'
|
|
)}</button>
|
|
<button class="linkish" data-action="open-album" data-id="${track.album?.id || ''}">${escapeHtml(
|
|
track.album?.title || track.albumTitle || 'Unknown album'
|
|
)}</button>
|
|
<div class="muted">${formatDuration(track.duration || 0)}${
|
|
track.audioQuality ? ` • ${escapeHtml(track.audioQuality)}` : ''
|
|
}</div>
|
|
<div class="detail-actions">
|
|
<button class="primary-btn" data-action="play-track-direct" data-id="${track.id}">Play</button>
|
|
<button class="mini-btn" data-action="download-track" data-id="${track.id}">Download</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="page-section">
|
|
<h3>Recommendations</h3>
|
|
${renderTrackList(recs, { queueKey: `track:${track.id}:recs` })}
|
|
</section>
|
|
`;
|
|
|
|
state.currentQueue = [track, ...recs];
|
|
} catch (error) {
|
|
els.pageContent.innerHTML = `<div class="error-block">Track load failed: ${escapeHtml(error.message || 'Unknown error')}</div>`;
|
|
}
|
|
}
|
|
|
|
async function renderRoute() {
|
|
const { page, id } = parseRoute();
|
|
|
|
if (page === 'home') {
|
|
renderHome();
|
|
return;
|
|
}
|
|
|
|
if (page === 'album' && id) {
|
|
await renderAlbumPage(id);
|
|
return;
|
|
}
|
|
|
|
if (page === 'artist' && id) {
|
|
await renderArtistPage(id);
|
|
return;
|
|
}
|
|
|
|
if (page === 'track' && id) {
|
|
await renderTrackPage(id);
|
|
return;
|
|
}
|
|
|
|
navigate('/home');
|
|
}
|
|
|
|
async function runSearch(query) {
|
|
const trimmed = query.trim();
|
|
if (!trimmed) return;
|
|
|
|
setSearchLoading(true);
|
|
setStatus(`Searching for "${trimmed}"...`, 'neutral');
|
|
|
|
try {
|
|
const [tracksRes, albumsRes, artistsRes] = await Promise.all([
|
|
api.searchTracks(trimmed).catch(() => ({ items: [] })),
|
|
api.searchAlbums(trimmed).catch(() => ({ items: [] })),
|
|
api.searchArtists(trimmed).catch(() => ({ items: [] })),
|
|
]);
|
|
|
|
state.query = trimmed;
|
|
state.tracks = (tracksRes.items || []).map((t) => normalizeTrack(t));
|
|
state.albums = albumsRes.items || [];
|
|
state.artists = artistsRes.items || [];
|
|
|
|
const total = state.tracks.length + state.albums.length + state.artists.length;
|
|
setStatus(`Found ${total} result${total === 1 ? '' : 's'}.`, 'ok');
|
|
navigate('/home');
|
|
renderHome();
|
|
} catch (error) {
|
|
state.query = trimmed;
|
|
state.tracks = [];
|
|
state.albums = [];
|
|
state.artists = [];
|
|
renderHome();
|
|
setStatus(`Search failed: ${error.message || 'Unknown error'}`, 'error');
|
|
} finally {
|
|
setSearchLoading(false);
|
|
}
|
|
}
|
|
|
|
async function playQueueAt(queue, index) {
|
|
if (!Array.isArray(queue) || queue.length === 0) return;
|
|
if (!Number.isInteger(index) || index < 0 || index >= queue.length) return;
|
|
|
|
player.setQueue(queue, index);
|
|
|
|
try {
|
|
const track = queue[index];
|
|
setStatus(`Loading: ${track.title || 'Track'}`, 'neutral');
|
|
await player.playCurrent((t) => api.getTrackStream(t.id, 'HI_RES_LOSSLESS'));
|
|
setStatus(`Playing: ${track.title || 'Track'}`, 'ok');
|
|
} catch (error) {
|
|
setStatus(`Playback failed: ${error.message || 'Unknown error'}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function playDirectTrack(id) {
|
|
const track = await api.getTrackMetadata(id);
|
|
await playQueueAt([normalizeTrack(track)], 0);
|
|
}
|
|
|
|
function getContextQueue(context) {
|
|
if (context === 'search:tracks') return state.tracks;
|
|
|
|
const { page } = parseRoute();
|
|
if (page === 'album' || page === 'artist' || page === 'track') return state.currentQueue;
|
|
|
|
return state.tracks;
|
|
}
|
|
|
|
function renderDownloads(tasks) {
|
|
if (!tasks.length) {
|
|
els.downloadsList.innerHTML = '<li class="download-empty">No active downloads.</li>';
|
|
return;
|
|
}
|
|
|
|
els.downloadsList.innerHTML = tasks
|
|
.map(
|
|
(task) => `
|
|
<li class="download-row">
|
|
<div class="download-meta">
|
|
<div class="download-title">${escapeHtml(task.title || 'Download')}</div>
|
|
<div class="download-message">${escapeHtml(task.message || task.status)}</div>
|
|
</div>
|
|
<div class="download-actions">
|
|
<progress max="100" value="${Number(task.percent || 0)}"></progress>
|
|
${task.status === 'downloading' ? `<button class="mini-btn" data-action="cancel-download" data-id="${task.id}">Cancel</button>` : ''}
|
|
</div>
|
|
</li>
|
|
`
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
async function ensureLyricsForCurrentTrack() {
|
|
const track = player.getCurrentTrack();
|
|
if (!track) {
|
|
els.lyricsContent.innerHTML = '<div class="lyrics-empty">No track playing.</div>';
|
|
return;
|
|
}
|
|
|
|
if (state.currentLyricsTrackId === track.id && state.currentLyrics) {
|
|
return;
|
|
}
|
|
|
|
els.lyricsContent.innerHTML = '<div class="lyrics-empty">Loading lyrics...</div>';
|
|
const payload = await lyrics.fetchLyrics(track);
|
|
state.currentLyrics = payload;
|
|
state.currentLyricsTrackId = track.id;
|
|
const currentTime = els.audio.currentTime || 0;
|
|
const idx = lyrics.getCurrentLineIndex(currentTime, payload.lines);
|
|
els.lyricsContent.innerHTML = lyrics.renderLyrics(payload, idx);
|
|
}
|
|
|
|
function toggleLyricsPanel(forceOpen = null) {
|
|
const open = forceOpen === null ? els.lyricsPanel.classList.contains('hidden') : forceOpen;
|
|
els.lyricsPanel.classList.toggle('hidden', !open);
|
|
|
|
if (open) {
|
|
ensureLyricsForCurrentTrack();
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
els.form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
await runSearch(els.input.value);
|
|
});
|
|
|
|
els.input.addEventListener(
|
|
'input',
|
|
debounce(() => {
|
|
const value = els.input.value.trim();
|
|
if (value.length >= 3) runSearch(value);
|
|
}, 420)
|
|
);
|
|
|
|
els.tabs.addEventListener('click', (event) => {
|
|
const tab = event.target.closest('.tab');
|
|
if (!tab) return;
|
|
setActiveTab(tab.dataset.tab);
|
|
renderHome();
|
|
});
|
|
|
|
els.backBtn.addEventListener('click', goBack);
|
|
window.addEventListener('hashchange', renderRoute);
|
|
|
|
els.pageContent.addEventListener('click', async (event) => {
|
|
const actionEl = event.target.closest('[data-action]');
|
|
if (!actionEl) return;
|
|
|
|
const action = actionEl.dataset.action;
|
|
const id = actionEl.dataset.id;
|
|
|
|
if (action === 'open-album' && id) navigate(`/album/${id}`);
|
|
if (action === 'open-artist' && id) navigate(`/artist/${id}`);
|
|
if (action === 'open-track' && id) navigate(`/track/${id}`);
|
|
|
|
if (action === 'play-track') {
|
|
const index = Number(actionEl.dataset.index || -1);
|
|
const context = actionEl.dataset.context || '';
|
|
const queue = getContextQueue(context);
|
|
await playQueueAt(queue, index);
|
|
}
|
|
|
|
if (action === 'play-track-direct' && id) await playDirectTrack(id);
|
|
if (action === 'play-album') await playQueueAt(state.currentQueue, 0);
|
|
if (action === 'play-top-tracks') await playQueueAt(state.currentQueue, 0);
|
|
|
|
if (action === 'download-track' && id) {
|
|
try {
|
|
const track = await api.getTrackMetadata(id);
|
|
await downloads.downloadTrack(normalizeTrack(track), 'LOSSLESS');
|
|
setStatus('Download started.', 'ok');
|
|
} catch (error) {
|
|
setStatus(`Download failed: ${error.message || 'Unknown error'}`, 'error');
|
|
}
|
|
}
|
|
|
|
if (action === 'download-album') {
|
|
try {
|
|
const { page } = parseRoute();
|
|
if (page !== 'album') return;
|
|
const route = parseRoute();
|
|
const { album, tracks } = await api.getAlbum(route.id);
|
|
await downloads.downloadAlbum(album, tracks.map((t) => normalizeTrack(t)), 'LOSSLESS');
|
|
setStatus('Album download queued.', 'ok');
|
|
} catch (error) {
|
|
setStatus(`Album download failed: ${error.message || 'Unknown error'}`, 'error');
|
|
}
|
|
}
|
|
|
|
if (action === 'cancel-download' && id) downloads.cancel(id);
|
|
});
|
|
|
|
els.playPauseBtn.addEventListener('click', async () => {
|
|
try {
|
|
await player.togglePlayPause();
|
|
} catch (error) {
|
|
setStatus(`Playback error: ${error.message || 'Unknown error'}`, 'error');
|
|
}
|
|
});
|
|
|
|
els.prevBtn.addEventListener('click', async () => {
|
|
try {
|
|
await player.playPrev((t) => api.getTrackStream(t.id, 'HI_RES_LOSSLESS'));
|
|
} catch (error) {
|
|
setStatus(`Previous track failed: ${error.message || 'Unknown error'}`, 'error');
|
|
}
|
|
});
|
|
|
|
els.nextBtn.addEventListener('click', async () => {
|
|
try {
|
|
await player.playNext((t) => api.getTrackStream(t.id, 'HI_RES_LOSSLESS'));
|
|
} catch (error) {
|
|
setStatus(`Next track failed: ${error.message || 'Unknown error'}`, 'error');
|
|
}
|
|
});
|
|
|
|
els.audio.addEventListener('play', setPlayButtonState);
|
|
els.audio.addEventListener('pause', setPlayButtonState);
|
|
|
|
player.onTrackChanged = (track) => {
|
|
updatePlayerMeta(track);
|
|
state.currentLyricsTrackId = null;
|
|
if (!els.lyricsPanel.classList.contains('hidden')) {
|
|
ensureLyricsForCurrentTrack();
|
|
}
|
|
};
|
|
|
|
player.onTimeUpdate = (currentTime) => {
|
|
if (els.lyricsPanel.classList.contains('hidden')) return;
|
|
if (!state.currentLyrics || !state.currentLyrics.synced) return;
|
|
|
|
const idx = lyrics.getCurrentLineIndex(currentTime, state.currentLyrics.lines);
|
|
els.lyricsContent.innerHTML = lyrics.renderLyrics(state.currentLyrics, idx);
|
|
};
|
|
|
|
player.setResolver((track) => api.getTrackStream(track.id, 'HI_RES_LOSSLESS'));
|
|
|
|
els.lyricsBtn.addEventListener('click', () => toggleLyricsPanel());
|
|
els.lyricsCloseBtn.addEventListener('click', () => toggleLyricsPanel(false));
|
|
|
|
els.lyricsContent.addEventListener('click', (event) => {
|
|
const line = event.target.closest('[data-time]');
|
|
if (!line) return;
|
|
const time = Number(line.dataset.time || 0);
|
|
if (Number.isFinite(time) && time >= 0) {
|
|
els.audio.currentTime = time;
|
|
}
|
|
});
|
|
|
|
downloads.setOnChange(renderDownloads);
|
|
els.downloadsClearBtn.addEventListener('click', () => downloads.clearFinished());
|
|
}
|
|
|
|
async function bootstrap() {
|
|
setStatus('Loading API instances...', 'neutral');
|
|
|
|
try {
|
|
await api.initInstances();
|
|
setStatus('Ready.', 'ok');
|
|
} catch {
|
|
setStatus('Instance loading failed, using fallback list.', 'error');
|
|
}
|
|
|
|
bindEvents();
|
|
setPlayButtonState();
|
|
|
|
if (!window.location.hash) {
|
|
navigate('/home');
|
|
} else {
|
|
renderRoute();
|
|
}
|
|
}
|
|
|
|
bootstrap();
|