Files
client/greyscale/src/app.js
T
2026-04-17 16:52:51 -04:00

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();