From 0c9dec35ff3c6753f12bde27106075426bed88d5 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 22 Oct 2025 10:31:45 +0300 Subject: [PATCH] c --- js/api.js | 105 ++++++++++++++++++---------------- js/app.js | 153 +++++++++++++++++++++++++++++++------------------- js/lastfm.js | 1 + js/player.js | 128 ++++++++++++++++++++++++----------------- js/storage.js | 1 + js/ui.js | 119 +++++++++++++++++++++++++++++++-------- styles.css | 92 +++++++++++++++++++++++++++++- 7 files changed, 417 insertions(+), 182 deletions(-) diff --git a/js/api.js b/js/api.js index 2d809d5..4722f58 100644 --- a/js/api.js +++ b/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}`; diff --git a/js/app.js b/js/app.js index d477d0a..2f274ff 100644 --- a/js/app.js +++ b/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'); diff --git a/js/lastfm.js b/js/lastfm.js index 5e87ae7..eeb1241 100644 --- a/js/lastfm.js +++ b/js/lastfm.js @@ -1,3 +1,4 @@ +//lastfm.js import { delay } from './utils.js'; export class LastFMScrobbler { diff --git a/js/player.js b/js/player.js index 67a1e6b..d2210cb 100644 --- a/js/player.js +++ b/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); } } -} \ No newline at end of file +} diff --git a/js/storage.js b/js/storage.js index e148988..6a6fa80 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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', diff --git a/js/ui.js b/js/ui.js index 2a11137..c68ba5c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -11,28 +11,46 @@ export class UIRenderer { return 'E'; } + createTrackMenuButton() { + return ` + + `; +} createTrackItemHTML(track, index, showCover = false) { - const playIconSmall = ''; - const trackNumberHTML = `
${showCover ? playIconSmall : index + 1}
`; - const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : ''; - - return ` -
- ${trackNumberHTML} -
- ${showCover ? `Track Cover` : ''} -
-
- ${track.title} - ${explicitBadge} -
-
${track.artist?.name ?? 'Unknown Artist'}
+ const playIconSmall = ''; + const trackNumberHTML = `
${showCover ? playIconSmall : index + 1}
`; + const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : ''; + + return ` +
+ ${trackNumberHTML} +
+ ${showCover ? `Track Cover` : ''} +
+
+ ${track.title} + ${explicitBadge}
+
${track.artist?.name ?? 'Unknown Artist'}
-
${formatTime(track.duration)}
- `; - } +
${formatTime(track.duration)}
+ +
+ `; +} 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 => ` + +
+ ${playlist.title} +
+

${playlist.title}

+

${playlist.numberOfTracks} tracks

+
+ `).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'); diff --git a/styles.css b/styles.css index c4ac053..4f1043a 100644 --- a/styles.css +++ b/styles.css @@ -1762,4 +1762,94 @@ input:checked + .slider:before { display: flex; align-items: center; gap: 0.5rem; -} \ No newline at end of file +} +.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; + } +} +