This commit is contained in:
Eduard Prigoana 2025-10-22 10:31:45 +03:00
parent 5be3311133
commit 0c9dec35ff
7 changed files with 417 additions and 182 deletions

105
js/api.js
View file

@ -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
View file

@ -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');

View file

@ -1,3 +1,4 @@
//lastfm.js
import { delay } from './utils.js';
export class LastFMScrobbler {

View file

@ -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);
}
}
}
}

View file

@ -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
View file

@ -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');

View file

@ -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;
}
}