c
This commit is contained in:
parent
5be3311133
commit
0c9dec35ff
7 changed files with 417 additions and 182 deletions
105
js/api.js
105
js/api.js
|
|
@ -309,60 +309,67 @@ export class LosslessAPI {
|
|||
return result;
|
||||
}
|
||||
|
||||
async getArtist(id) {
|
||||
const cached = await this.cache.get('artist', id);
|
||||
if (cached) return cached;
|
||||
async getArtist(artistId) {
|
||||
const cached = await this.cache.get('artist', artistId);
|
||||
if (cached) return cached;
|
||||
|
||||
const [primaryResponse, contentResponse] = await Promise.all([
|
||||
this.fetchWithRetry(`/artist/?id=${id}`),
|
||||
this.fetchWithRetry(`/artist/?f=${id}`)
|
||||
]);
|
||||
const [primaryResponse, contentResponse] = await Promise.all([
|
||||
this.fetchWithRetry(`/artist/?id=${artistId}`),
|
||||
this.fetchWithRetry(`/artist/?f=${artistId}`)
|
||||
]);
|
||||
|
||||
const primaryData = await primaryResponse.json();
|
||||
const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData;
|
||||
|
||||
if (!rawArtist) throw new Error('Primary artist details not found.');
|
||||
|
||||
// Ensure artist has required fields
|
||||
const artist = {
|
||||
...this.prepareArtist(rawArtist),
|
||||
picture: rawArtist.picture || null,
|
||||
name: rawArtist.name || 'Unknown Artist'
|
||||
};
|
||||
|
||||
const contentData = await contentResponse.json();
|
||||
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
||||
|
||||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
|
||||
const isTrack = v => v?.id && v.duration && v.album;
|
||||
const isAlbum = v => v?.id && 'numberOfTracks' in v;
|
||||
|
||||
const scan = (value, visited = new Set()) => {
|
||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
|
||||
const primaryData = await primaryResponse.json();
|
||||
const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData);
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => scan(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!artist) throw new Error('Primary artist details not found.');
|
||||
const item = value.item || value;
|
||||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||
|
||||
const contentData = await contentResponse.json();
|
||||
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
||||
|
||||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
|
||||
const isTrack = v => v?.id && v.duration && v.album;
|
||||
const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v;
|
||||
|
||||
const scan = (value, visited = new Set()) => {
|
||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => scan(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
const item = value.item || value;
|
||||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||
|
||||
Object.values(value).forEach(nested => scan(nested, visited));
|
||||
};
|
||||
|
||||
entries.forEach(entry => scan(entry));
|
||||
|
||||
const albums = Array.from(albumMap.values()).sort((a, b) =>
|
||||
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
||||
const tracks = Array.from(trackMap.values())
|
||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
.slice(0, 10);
|
||||
|
||||
const result = { ...artist, albums, tracks };
|
||||
Object.values(value).forEach(nested => scan(nested, visited));
|
||||
};
|
||||
|
||||
entries.forEach(entry => scan(entry));
|
||||
|
||||
const albums = Array.from(albumMap.values()).sort((a, b) =>
|
||||
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
||||
const tracks = Array.from(trackMap.values())
|
||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
.slice(0, 10);
|
||||
|
||||
const result = { ...artist, albums, tracks };
|
||||
|
||||
await this.cache.set('artist', id, result);
|
||||
return result;
|
||||
}
|
||||
await this.cache.set('artist', artistId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTrack(id, quality = 'LOSSLESS') {
|
||||
const cacheKey = `${id}_${quality}`;
|
||||
|
|
|
|||
153
js/app.js
153
js/app.js
|
|
@ -1,4 +1,3 @@
|
|||
//app.js
|
||||
import { LosslessAPI } from './api.js';
|
||||
import { apiSettings, themeManager, lastFMStorage } from './storage.js';
|
||||
import { UIRenderer } from './ui.js';
|
||||
|
|
@ -331,6 +330,21 @@ function completeBulkDownload(notifEl, success = true, message = null) {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadHomeFeed(api) {
|
||||
try {
|
||||
const response = await api.fetchWithRetry('/home/');
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) return null;
|
||||
|
||||
const homeData = data[0];
|
||||
return homeData;
|
||||
} catch (error) {
|
||||
console.error('Failed to load home feed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const api = new LosslessAPI(apiSettings);
|
||||
const ui = new UIRenderer(api);
|
||||
|
|
@ -380,6 +394,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const lastfmToggle = document.getElementById('lastfm-toggle');
|
||||
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
|
||||
|
||||
window.loadHomeFeed = loadHomeFeed;
|
||||
|
||||
function updateLastFMUI() {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
|
||||
|
|
@ -397,75 +413,73 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
updateLastFMUI();
|
||||
|
||||
lastfmConnectBtn?.addEventListener('click', async () => {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
if (confirm('Disconnect from Last.fm?')) {
|
||||
scrobbler.disconnect();
|
||||
updateLastFMUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const authWindow = window.open('', '_blank');
|
||||
|
||||
lastfmConnectBtn.disabled = true;
|
||||
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
||||
|
||||
try {
|
||||
const { token, url } = await scrobbler.getAuthUrl();
|
||||
|
||||
if (authWindow) {
|
||||
authWindow.location.href = url;
|
||||
} else {
|
||||
alert('Popup blocked! Please allow popups.');
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
lastfmConnectBtn?.addEventListener('click', async () => {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
if (confirm('Disconnect from Last.fm?')) {
|
||||
scrobbler.disconnect();
|
||||
updateLastFMUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastfmConnectBtn.textContent = 'Waiting for authorization...';
|
||||
const authWindow = window.open('', '_blank');
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
lastfmConnectBtn.disabled = true;
|
||||
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
||||
|
||||
const checkAuth = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const { token, url } = await scrobbler.getAuthUrl();
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(checkAuth);
|
||||
if (authWindow) {
|
||||
authWindow.location.href = url;
|
||||
} else {
|
||||
alert('Popup blocked! Please allow popups.');
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
alert('Authorization timed out. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scrobbler.completeAuthentication(token);
|
||||
lastfmConnectBtn.textContent = 'Waiting for authorization...';
|
||||
|
||||
if (result.success) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
|
||||
const checkAuth = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(checkAuth);
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
updateLastFMUI();
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
lastFMStorage.setEnabled(true);
|
||||
lastfmToggle.checked = true;
|
||||
alert(`Successfully connected to Last.fm as ${result.username}!`);
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
alert('Authorization timed out. Please try again.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Last.fm connection failed:', error);
|
||||
alert('Failed to connect to Last.fm: ' + error.message);
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const result = await scrobbler.completeAuthentication(token);
|
||||
|
||||
if (result.success) {
|
||||
clearInterval(checkAuth);
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
updateLastFMUI();
|
||||
lastfmConnectBtn.disabled = false;
|
||||
lastFMStorage.setEnabled(true);
|
||||
lastfmToggle.checked = true;
|
||||
alert(`Successfully connected to Last.fm as ${result.username}!`);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Last.fm connection failed:', error);
|
||||
alert('Failed to connect to Last.fm: ' + error.message);
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
}
|
||||
});
|
||||
|
||||
lastfmToggle?.addEventListener('change', (e) => {
|
||||
lastFMStorage.setEnabled(e.target.checked);
|
||||
|
|
@ -583,6 +597,19 @@ lastfmConnectBtn?.addEventListener('click', async () => {
|
|||
});
|
||||
}
|
||||
|
||||
const normalizeToggle = document.querySelectorAll('.setting-item').forEach(item => {
|
||||
const label = item.querySelector('.label');
|
||||
if (label && label.textContent.includes('Normalize Volume')) {
|
||||
const toggle = item.querySelector('input[type="checkbox"]');
|
||||
if (toggle) {
|
||||
toggle.checked = localStorage.getItem('normalize-volume') === 'true';
|
||||
toggle.addEventListener('change', (e) => {
|
||||
localStorage.setItem('normalize-volume', e.target.checked ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
|
||||
const track = player.currentTrack;
|
||||
if (track?.album?.id) {
|
||||
|
|
@ -699,10 +726,6 @@ lastfmConnectBtn?.addEventListener('click', async () => {
|
|||
};
|
||||
|
||||
const renderQueue = () => {
|
||||
if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQueue = player.getCurrentQueue();
|
||||
|
||||
if (currentQueue.length === 0) {
|
||||
|
|
@ -814,6 +837,22 @@ lastfmConnectBtn?.addEventListener('click', async () => {
|
|||
});
|
||||
|
||||
mainContent.addEventListener('click', e => {
|
||||
const menuBtn = e.target.closest('.track-menu-btn');
|
||||
if (menuBtn) {
|
||||
e.stopPropagation();
|
||||
const trackItem = menuBtn.closest('.track-item');
|
||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||
contextTrack = trackDataStore.get(trackItem);
|
||||
if (contextTrack) {
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
contextMenu.style.top = `${rect.bottom + 5}px`;
|
||||
contextMenu.style.left = `${rect.left}px`;
|
||||
contextMenu.style.display = 'block';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trackItem = e.target.closest('.track-item');
|
||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||
const parentList = trackItem.closest('.track-list');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//lastfm.js
|
||||
import { delay } from './utils.js';
|
||||
|
||||
export class LastFMScrobbler {
|
||||
|
|
|
|||
128
js/player.js
128
js/player.js
|
|
@ -119,58 +119,74 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
async playTrackFromQueue() {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
this.currentTrack = track;
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
document.querySelector('.now-playing-bar .title').textContent = track.title;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
|
||||
document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`;
|
||||
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
||||
try {
|
||||
let streamUrl;
|
||||
|
||||
if (this.preloadCache.has(track.id)) {
|
||||
streamUrl = this.preloadCache.get(track.id);
|
||||
} else {
|
||||
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
||||
}
|
||||
|
||||
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
|
||||
const temp = this.audio;
|
||||
this.audio = this.nextAudioElement;
|
||||
this.nextAudioElement = temp;
|
||||
|
||||
this.nextAudioElement.pause();
|
||||
this.nextAudioElement.currentTime = 0;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
}
|
||||
|
||||
await this.audio.play();
|
||||
this.isCrossfading = false;
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
this.preloadNextTracks();
|
||||
this.setupCrossfadeListener();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Could not play track: ${track.title}`, error);
|
||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
||||
}
|
||||
async playTrackFromQueue() {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
this.currentTrack = track;
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
document.querySelector('.now-playing-bar .title').textContent = track.title;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
|
||||
document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`;
|
||||
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
||||
try {
|
||||
let streamUrl;
|
||||
|
||||
if (this.preloadCache.has(track.id)) {
|
||||
streamUrl = this.preloadCache.get(track.id);
|
||||
} else {
|
||||
const trackData = await this.api.getTrack(track.id, this.quality);
|
||||
|
||||
// Store replayGain for normalization
|
||||
if (trackData.track?.replayGain !== undefined) {
|
||||
window.currentGain = trackData.track.replayGain;
|
||||
} else {
|
||||
window.currentGain = track.replayGain || null;
|
||||
}
|
||||
|
||||
if (trackData.originalTrackUrl) {
|
||||
streamUrl = trackData.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
|
||||
const temp = this.audio;
|
||||
this.audio = this.nextAudioElement;
|
||||
this.nextAudioElement = temp;
|
||||
|
||||
this.nextAudioElement.pause();
|
||||
this.nextAudioElement.currentTime = 0;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
}
|
||||
|
||||
// Apply normalization if enabled
|
||||
this.applyNormalization();
|
||||
|
||||
await this.audio.play();
|
||||
this.isCrossfading = false;
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
this.preloadNextTracks();
|
||||
this.setupCrossfadeListener();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Could not play track: ${track.title}`, error);
|
||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
||||
}
|
||||
}
|
||||
|
||||
setupCrossfadeListener() {
|
||||
if (!this.crossfadeEnabled) return;
|
||||
|
||||
|
|
@ -415,6 +431,16 @@ export class Player {
|
|||
this.updateMediaSessionPlaybackState();
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
applyNormalization() {
|
||||
const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true';
|
||||
|
||||
if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) {
|
||||
const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7');
|
||||
const replayGain = parseFloat(window.currentGain);
|
||||
const adjustment = Math.pow(10, replayGain / 20);
|
||||
this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment));
|
||||
}
|
||||
}
|
||||
|
||||
updateMediaSessionPlaybackState() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
|
@ -441,4 +467,4 @@ export class Player {
|
|||
console.debug('Failed to update Media Session position:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//storage.js
|
||||
export const apiSettings = {
|
||||
STORAGE_KEY: 'monochrome-api-instances',
|
||||
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',
|
||||
|
|
|
|||
119
js/ui.js
119
js/ui.js
|
|
@ -11,28 +11,46 @@ export class UIRenderer {
|
|||
return '<span class="explicit-badge" title="Explicit">E</span>';
|
||||
}
|
||||
|
||||
createTrackMenuButton() {
|
||||
return `
|
||||
<button class="track-menu-btn" onclick="event.stopPropagation();" title="More options">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
createTrackItemHTML(track, index, showCover = false) {
|
||||
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
${trackNumberHTML}
|
||||
<div class="track-item-info">
|
||||
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${track.title}
|
||||
${explicitBadge}
|
||||
</div>
|
||||
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
|
||||
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
${trackNumberHTML}
|
||||
<div class="track-item-info">
|
||||
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${track.title}
|
||||
${explicitBadge}
|
||||
</div>
|
||||
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
<button class="track-menu-btn" type="button" title="More options">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createAlbumCardHTML(album) {
|
||||
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
|
||||
|
|
@ -130,18 +148,71 @@ export class UIRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
renderHomePage() {
|
||||
this.showPage('home');
|
||||
const recents = recentActivityManager.getRecents();
|
||||
|
||||
document.getElementById('home-recent-albums').innerHTML = recents.albums.length
|
||||
async renderHomePage() {
|
||||
this.showPage('home');
|
||||
const recents = recentActivityManager.getRecents();
|
||||
|
||||
const albumsContainer = document.getElementById('home-recent-albums');
|
||||
const artistsContainer = document.getElementById('home-recent-artists');
|
||||
|
||||
if (recents.albums.length > 0 || recents.artists.length > 0) {
|
||||
albumsContainer.innerHTML = recents.albums.length
|
||||
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
||||
: createPlaceholder("You haven't viewed any albums yet.");
|
||||
|
||||
document.getElementById('home-recent-artists').innerHTML = recents.artists.length
|
||||
artistsContainer.innerHTML = recents.artists.length
|
||||
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
|
||||
: createPlaceholder("You haven't viewed any artists yet.");
|
||||
} else {
|
||||
// Load from API
|
||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||
|
||||
const homeData = await window.loadHomeFeed(this.api, this);
|
||||
|
||||
if (homeData && homeData.rows) {
|
||||
let albums = [];
|
||||
let playlists = [];
|
||||
|
||||
homeData.rows.forEach(row => {
|
||||
row.modules?.forEach(module => {
|
||||
if (module.type === 'ALBUM_LIST' && module.pagedList?.items) {
|
||||
albums.push(...module.pagedList.items);
|
||||
} else if (module.type === 'PLAYLIST_LIST' && module.pagedList?.items) {
|
||||
playlists.push(...module.pagedList.items);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (albums.length > 0) {
|
||||
albumsContainer.innerHTML = albums.slice(0, 10).map(album =>
|
||||
this.createAlbumCardHTML(album)
|
||||
).join('');
|
||||
} else {
|
||||
albumsContainer.innerHTML = createPlaceholder("No albums available.");
|
||||
}
|
||||
|
||||
if (playlists.length > 0) {
|
||||
document.querySelector('#home-recent-artists').parentElement.querySelector('.section-title').textContent = 'Featured Playlists';
|
||||
artistsContainer.innerHTML = playlists.slice(0, 10).map(playlist => `
|
||||
<a href="#playlist/${playlist.uuid}" class="card">
|
||||
<div class="card-image-wrapper">
|
||||
<img src="${this.api.getCoverUrl(playlist.image || playlist.squareImage, '320')}"
|
||||
alt="${playlist.title}" class="card-image" loading="lazy">
|
||||
</div>
|
||||
<h3 class="card-title">${playlist.title}</h3>
|
||||
<p class="card-subtitle">${playlist.numberOfTracks} tracks</p>
|
||||
</a>
|
||||
`).join('');
|
||||
} else {
|
||||
artistsContainer.innerHTML = createPlaceholder("No playlists available.");
|
||||
}
|
||||
} else {
|
||||
albumsContainer.innerHTML = createPlaceholder("Unable to load content.");
|
||||
artistsContainer.innerHTML = createPlaceholder("Unable to load content.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async renderSearchPage(query) {
|
||||
this.showPage('search');
|
||||
|
|
|
|||
92
styles.css
92
styles.css
|
|
@ -1762,4 +1762,94 @@ input:checked + .slider:before {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-menu-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: all .2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.track-item:hover .track-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-menu-btn:hover {
|
||||
background-color: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.track-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-menu-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: all .2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: all;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.track-item:hover .track-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-menu-btn:hover {
|
||||
background-color: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.track-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.track-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue