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

View file

@ -309,19 +309,26 @@ export class LosslessAPI {
return result;
}
async getArtist(id) {
const cached = await this.cache.get('artist', id);
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}`)
this.fetchWithRetry(`/artist/?id=${artistId}`),
this.fetchWithRetry(`/artist/?f=${artistId}`)
]);
const primaryData = await primaryResponse.json();
const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData);
const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData;
if (!artist) throw new Error('Primary artist details not found.');
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];
@ -330,7 +337,7 @@ export class LosslessAPI {
const trackMap = new Map();
const isTrack = v => v?.id && v.duration && v.album;
const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v;
const isAlbum = v => v?.id && 'numberOfTracks' in v;
const scan = (value, visited = new Set()) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
@ -360,7 +367,7 @@ export class LosslessAPI {
const result = { ...artist, albums, tracks };
await this.cache.set('artist', id, result);
await this.cache.set('artist', artistId, result);
return result;
}

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}`;
@ -406,7 +422,6 @@ lastfmConnectBtn?.addEventListener('click', async () => {
return;
}
const authWindow = window.open('', '_blank');
lastfmConnectBtn.disabled = true;
@ -466,7 +481,6 @@ lastfmConnectBtn?.addEventListener('click', async () => {
}
});
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

@ -143,7 +143,20 @@ export class Player {
if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id);
} else {
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
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) {
@ -157,6 +170,9 @@ export class Player {
this.audio.src = streamUrl;
}
// Apply normalization if enabled
this.applyNormalization();
await this.audio.play();
this.isCrossfading = false;
@ -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;

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',

View file

@ -11,6 +11,17 @@ 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>`;
@ -30,6 +41,13 @@ export class UIRenderer {
</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>
`;
}
@ -130,17 +148,70 @@ export class UIRenderer {
}
}
renderHomePage() {
async renderHomePage() {
this.showPage('home');
const recents = recentActivityManager.getRecents();
document.getElementById('home-recent-albums').innerHTML = recents.albums.length
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) {

View file

@ -1763,3 +1763,93 @@ input:checked + .slider:before {
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;
}
}