From ab0ef64e01394a26de58ea6c622d8dc712f11107 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Mon, 20 Oct 2025 17:45:25 +0300 Subject: [PATCH 01/11] button --- assets/button/880x310.png | Bin 0 -> 14657 bytes assets/button/88x31.png | Bin 0 -> 921 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/button/880x310.png create mode 100644 assets/button/88x31.png diff --git a/assets/button/880x310.png b/assets/button/880x310.png new file mode 100644 index 0000000000000000000000000000000000000000..07f4108d7754c17766cb71c717f9986e4d0c761b GIT binary patch literal 14657 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 Date: Mon, 20 Oct 2025 23:12:12 +0300 Subject: [PATCH 02/11] Remove duplicate entry from instances.json --- instances.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/instances.json b/instances.json index dfc5080..6d52f95 100644 --- a/instances.json +++ b/instances.json @@ -1,5 +1,4 @@ [ - "https://tidal.401658.xyz", "https://triton.squid.wtf", "https://aether.squid.wtf", "https://zeus.squid.wtf", @@ -11,5 +10,5 @@ "https://hund.qqdl.site", "https://phoenix.squid.wtf", "https://shiva.squid.wtf", - "https://chaos.squid.wtf" + "https://chaos.squid.wtf", ] From f98a9515f16ae14d6c5d600109e1a8c570f283a6 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Mon, 20 Oct 2025 23:14:53 +0300 Subject: [PATCH 03/11] Fix formatting in instances.json --- instances.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instances.json b/instances.json index 6d52f95..4a7bc76 100644 --- a/instances.json +++ b/instances.json @@ -10,5 +10,5 @@ "https://hund.qqdl.site", "https://phoenix.squid.wtf", "https://shiva.squid.wtf", - "https://chaos.squid.wtf", + "https://chaos.squid.wtf" ] From 8296b0e9a39ef4b5a61c374303e146ed9b616397 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 21 Oct 2025 16:12:20 +0300 Subject: [PATCH 04/11] Update instances.json --- instances.json | 1 - 1 file changed, 1 deletion(-) diff --git a/instances.json b/instances.json index 4a7bc76..485767a 100644 --- a/instances.json +++ b/instances.json @@ -1,5 +1,4 @@ [ - "https://triton.squid.wtf", "https://aether.squid.wtf", "https://zeus.squid.wtf", "https://kraken.squid.wtf", From 05cfdf28f3a07ccecf02a5c28bd20d8d147f0776 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 21 Oct 2025 16:53:14 +0300 Subject: [PATCH 05/11] Update player.js --- js/player.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/player.js b/js/player.js index a39c5b5..5459478 100644 --- a/js/player.js +++ b/js/player.js @@ -128,7 +128,7 @@ export class Player { this.currentTrack = track; document.querySelector('.now-playing-bar .cover').src = - this.api.getCoverUrl(track.album?.cover, '160'); + 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'}`; @@ -391,7 +391,7 @@ export class Player { if (!('mediaSession' in navigator)) return; const artwork = []; - const sizes = ['96', '128', '192', '256', '384', '512']; + const sizes = ['1280']; const coverId = track.album?.cover; if (coverId) { From b0f6a11a94afa5a1dd84ec9ef5f021e31f302f7a Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 21 Oct 2025 17:07:01 +0300 Subject: [PATCH 06/11] Update storage.js --- js/storage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/storage.js b/js/storage.js index df2a66b..7ced455 100644 --- a/js/storage.js +++ b/js/storage.js @@ -53,8 +53,8 @@ export const apiSettings = { async speedTestInstance(url) { const testUrl = url.endsWith('/') - ? `${url}search/?s=kanye` - : `${url}/search/?s=kanye`; + ? `${url}track/?id=204567804&quality=HIGH` + : `${url}/track/?id=204567804&quality=HIGH`; const startTime = performance.now(); From 8ebc1542d52ccab4c8341cc142e9f1201649eaa6 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 21 Oct 2025 18:18:03 +0300 Subject: [PATCH 07/11] lastfm integration --- index.html | 21 ++++- js/app.js | 138 ++++++++++++++++++++++----- js/lastfm.js | 248 +++++++++++++++++++++++++++++++++++++++++++++++++ js/metadata.js | 210 ----------------------------------------- js/storage.js | 16 ++++ js/utils.js | 1 + manifest.json | 5 +- styles.css | 14 +++ 8 files changed, 418 insertions(+), 235 deletions(-) create mode 100644 js/lastfm.js delete mode 100644 js/metadata.js diff --git a/index.html b/index.html index d50b988..d000bea 100644 --- a/index.html +++ b/index.html @@ -222,7 +222,26 @@ - +
+
+ Last.fm Scrobbling + Connect your Last.fm account to scrobble tracks +
+
+ +
+
+ +
Audio Quality diff --git a/js/app.js b/js/app.js index 11f4acf..021a203 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,8 @@ import { LosslessAPI } from './api.js'; -import { apiSettings, themeManager } from './storage.js'; +import { apiSettings, themeManager, lastFMStorage } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; +import { LastFMScrobbler } from './lastfm.js'; import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore, @@ -337,6 +338,8 @@ document.addEventListener('DOMContentLoaded', async () => { const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS'; const player = new Player(audioPlayer, api, currentQuality); + const scrobbler = new LastFMScrobbler(); + const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true'; const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5'); player.setCrossfade(savedCrossfade, savedCrossfadeDuration); @@ -371,6 +374,90 @@ document.addEventListener('DOMContentLoaded', async () => { let contextTrack = null; let draggedQueueIndex = null; + const lastfmConnectBtn = document.getElementById('lastfm-connect-btn'); + const lastfmStatus = document.getElementById('lastfm-status'); + const lastfmToggle = document.getElementById('lastfm-toggle'); + const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting'); + + function updateLastFMUI() { + if (scrobbler.isAuthenticated()) { + lastfmStatus.textContent = `Connected as ${scrobbler.username}`; + lastfmConnectBtn.textContent = 'Disconnect'; + lastfmConnectBtn.classList.add('danger'); + lastfmToggleSetting.style.display = 'flex'; + lastfmToggle.checked = lastFMStorage.isEnabled(); + } else { + lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks'; + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.classList.remove('danger'); + lastfmToggleSetting.style.display = 'none'; + } + } + + updateLastFMUI(); + + lastfmConnectBtn?.addEventListener('click', async () => { + if (scrobbler.isAuthenticated()) { + if (confirm('Disconnect from Last.fm?')) { + scrobbler.disconnect(); + updateLastFMUI(); + } + } else { + try { + lastfmConnectBtn.disabled = true; + lastfmConnectBtn.textContent = 'Opening Last.fm...'; + + const { token, url } = await scrobbler.getAuthUrl(); + + const authWindow = window.open(url, 'lastfm-auth', 'width=800,height=600'); + + lastfmConnectBtn.textContent = 'Waiting for authorization...'; + + let attempts = 0; + const maxAttempts = 30; + + const checkAuth = setInterval(async () => { + attempts++; + + if (attempts > maxAttempts) { + clearInterval(checkAuth); + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.disabled = false; + alert('Authorization timed out. Please try again.'); + return; + } + + 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; + } + } + }); + + lastfmToggle?.addEventListener('change', (e) => { + lastFMStorage.setEnabled(e.target.checked); + }); + const themePicker = document.getElementById('theme-picker'); themePicker.querySelectorAll('.theme-option').forEach(option => { if (option.dataset.theme === currentTheme) { @@ -392,29 +479,31 @@ document.addEventListener('DOMContentLoaded', async () => { } }); }); + document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => { - const btn = document.getElementById('refresh-speed-test-btn'); - const originalText = btn.textContent; - btn.textContent = 'Testing...'; - btn.disabled = true; - - try { - await apiSettings.refreshSpeedTests(); - ui.renderApiSettings(); - btn.textContent = 'Done!'; - setTimeout(() => { - btn.textContent = originalText; - btn.disabled = false; - }, 1500); - } catch (error) { - console.error('Failed to refresh speed tests:', error); - btn.textContent = 'Error'; - setTimeout(() => { - btn.textContent = originalText; - btn.disabled = false; - }, 1500); - } -}); + const btn = document.getElementById('refresh-speed-test-btn'); + const originalText = btn.textContent; + btn.textContent = 'Testing...'; + btn.disabled = true; + + try { + await apiSettings.refreshSpeedTests(); + ui.renderApiSettings(); + btn.textContent = 'Done!'; + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + }, 1500); + } catch (error) { + console.error('Failed to refresh speed tests:', error); + btn.textContent = 'Error'; + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + }, 1500); + } + }); + function renderCustomThemeEditor() { const grid = document.getElementById('theme-color-grid'); const customTheme = themeManager.getCustomTheme() || { @@ -809,6 +898,9 @@ document.addEventListener('DOMContentLoaded', async () => { }); audioPlayer.addEventListener('play', () => { + if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) { + scrobbler.updateNowPlaying(player.currentTrack); + } playPauseBtn.innerHTML = SVG_PAUSE; player.updateMediaSessionPlaybackState(); }); diff --git a/js/lastfm.js b/js/lastfm.js new file mode 100644 index 0000000..5e87ae7 --- /dev/null +++ b/js/lastfm.js @@ -0,0 +1,248 @@ +import { delay } from './utils.js'; + +export class LastFMScrobbler { + constructor() { + this.API_KEY = '0fc32c426d943d34a662977b31b98b67'; + this.API_SECRET = '53acf2466be726db021e7fdfd0ad1084'; + this.API_URL = 'https://ws.audioscrobbler.com/2.0/'; + + this.sessionKey = null; + this.username = null; + this.currentTrack = null; + this.scrobbleTimer = null; + this.scrobbleThreshold = 0; + this.hasScrobbled = false; + + this.loadSession(); + } + + loadSession() { + try { + const session = localStorage.getItem('lastfm-session'); + if (session) { + const data = JSON.parse(session); + this.sessionKey = data.key; + this.username = data.name; + } + } catch (e) { + console.error('Failed to load Last.fm session:', e); + } + } + + saveSession(sessionKey, username) { + this.sessionKey = sessionKey; + this.username = username; + localStorage.setItem('lastfm-session', JSON.stringify({ + key: sessionKey, + name: username + })); + } + + clearSession() { + this.sessionKey = null; + this.username = null; + localStorage.removeItem('lastfm-session'); + } + + isAuthenticated() { + return !!this.sessionKey; + } + + async generateSignature(params) { + const filteredParams = { ...params }; + delete filteredParams.format; + delete filteredParams.callback; + + const sortedKeys = Object.keys(filteredParams).sort(); + + const signatureString = sortedKeys + .map(key => `${key}${filteredParams[key]}`) + .join('') + this.API_SECRET; + + console.log('Signature string:', signatureString); + + try { + const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm'); + return md5(signatureString); + } catch (e) { + console.error('MD5 library not available'); + throw new Error('MD5 library required for Last.fm'); + } + } + + async makeRequest(method, params = {}, requiresAuth = false) { + const requestParams = { + method, + api_key: this.API_KEY, + ...params + }; + + if (requiresAuth && this.sessionKey) { + requestParams.sk = this.sessionKey; + } + + const signature = await this.generateSignature(requestParams); + + const formData = new URLSearchParams({ + ...requestParams, + api_sig: signature, + format: 'json' + }); + + try { + const response = await fetch(this.API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.message || 'Last.fm API error'); + } + + return data; + } catch (error) { + console.error('Last.fm API request failed:', error); + throw error; + } + } + + async getAuthUrl() { + try { + const data = await this.makeRequest('auth.getToken'); + const token = data.token; + + return { + token, + url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}` + }; + } catch (error) { + console.error('Failed to get auth URL:', error); + throw error; + } + } + + async completeAuthentication(token) { + try { + const data = await this.makeRequest('auth.getSession', { token }); + + if (data.session) { + this.saveSession(data.session.key, data.session.name); + return { + success: true, + username: data.session.name + }; + } + + throw new Error('No session returned'); + } catch (error) { + console.error('Authentication failed:', error); + throw error; + } + } + + async updateNowPlaying(track) { + if (!this.isAuthenticated()) return; + + this.currentTrack = track; + this.hasScrobbled = false; + this.clearScrobbleTimer(); + + try { + const params = { + artist: track.artist?.name || 'Unknown Artist', + track: track.title + }; + + if (track.album?.title) { + params.album = track.album.title; + } + + if (track.duration) { + params.duration = Math.floor(track.duration); + } + + if (track.trackNumber) { + params.trackNumber = track.trackNumber; + } + + await this.makeRequest('track.updateNowPlaying', params, true); + + console.log('Now playing updated:', track.title); + + this.scrobbleThreshold = Math.min(track.duration / 2, 240); + this.scheduleScrobble(this.scrobbleThreshold * 1000); + + } catch (error) { + console.error('Failed to update now playing:', error); + } + } + + scheduleScrobble(delay) { + this.clearScrobbleTimer(); + + this.scrobbleTimer = setTimeout(() => { + this.scrobbleCurrentTrack(); + }, delay); + } + + clearScrobbleTimer() { + if (this.scrobbleTimer) { + clearTimeout(this.scrobbleTimer); + this.scrobbleTimer = null; + } + } + + async scrobbleCurrentTrack() { + if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return; + + try { + const timestamp = Math.floor(Date.now() / 1000); + + const params = { + artist: this.currentTrack.artist?.name || 'Unknown Artist', + track: this.currentTrack.title, + timestamp: timestamp + }; + + if (this.currentTrack.album?.title) { + params.album = this.currentTrack.album.title; + } + + if (this.currentTrack.duration) { + params.duration = Math.floor(this.currentTrack.duration); + } + + if (this.currentTrack.trackNumber) { + params.trackNumber = this.currentTrack.trackNumber; + } + + await this.makeRequest('track.scrobble', params, true); + + this.hasScrobbled = true; + console.log('Scrobbled:', this.currentTrack.title); + + } catch (error) { + console.error('Failed to scrobble:', error); + } + } + + onTrackChange(track) { + if (!this.isAuthenticated()) return; + this.updateNowPlaying(track); + } + + onPlaybackStop() { + this.clearScrobbleTimer(); + } + + disconnect() { + this.clearSession(); + this.clearScrobbleTimer(); + this.currentTrack = null; + } +} \ No newline at end of file diff --git a/js/metadata.js b/js/metadata.js deleted file mode 100644 index 82e4f77..0000000 --- a/js/metadata.js +++ /dev/null @@ -1,210 +0,0 @@ -export class MetadataEmbedder { - constructor() { - this.ffmpegLoaded = false; - this.ffmpeg = null; - this.fetchFile = null; - } - - async loadFFmpeg() { - if (this.ffmpegLoaded) return; - - try { - console.log('[FFmpeg] Loading FFmpeg...'); - - if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') { - throw new Error('FFmpeg libraries not loaded. Please check your internet connection.'); - } - - const { FFmpeg } = FFmpegWASM; - const { fetchFile } = FFmpegUtil; - - this.ffmpeg = new FFmpeg(); - this.fetchFile = fetchFile; - - this.ffmpeg.on('log', ({ message }) => { - console.log('[FFmpeg]', message); - }); - - const baseURL = window.location.origin + '/ffmpeg'; - - await this.ffmpeg.load({ - coreURL: `${baseURL}/ffmpeg-core.js`, - wasmURL: `${baseURL}/ffmpeg-core.wasm` - }); - - this.ffmpegLoaded = true; - console.log('[FFmpeg] Loaded successfully'); - } catch (error) { - console.error('[FFmpeg] Failed to load:', error); - throw error; - } - } - - async embedMetadata(audioBlob, track, coverImageUrl, onProgress) { - console.log('[Metadata] Starting embedding for:', track.title); - - if (!this.ffmpegLoaded) { - try { - await this.loadFFmpeg(); - } catch (error) { - console.error('[Metadata] Cannot load FFmpeg, skipping metadata:', error); - return audioBlob; - } - } - - if (!this.ffmpeg || !this.fetchFile) { - console.error('[Metadata] FFmpeg not properly initialized'); - return audioBlob; - } - - const inputName = 'input.flac'; - const coverName = 'cover.jpg'; - const outputName = 'output.flac'; - - try { - const arrayBuffer = await audioBlob.arrayBuffer(); - await this.ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer)); - console.log('[Metadata] Wrote input file:', inputName, 'size:', arrayBuffer.byteLength); - - let hasCover = false; - if (coverImageUrl) { - try { - console.log('[Metadata] Fetching cover from:', coverImageUrl); - const coverData = await this.fetchFile(coverImageUrl); - await this.ffmpeg.writeFile(coverName, coverData); - hasCover = true; - console.log('[Metadata] Cover image written successfully, size:', coverData.length); - } catch (coverError) { - console.warn('[Metadata] Failed to fetch cover image:', coverError); - } - } - - const metadata = this.buildMetadataArgs(track); - console.log('[Metadata] Building metadata with', metadata.length / 2, 'fields'); - - let args; - if (hasCover) { - args = [ - '-i', inputName, - '-i', coverName, - '-map', '0:a', - '-map', '1', - '-c:a', 'copy', - '-c:v', 'copy', - ...metadata, - '-metadata:s:v', 'title=Album cover', - '-metadata:s:v', 'comment=Cover (front)', - '-disposition:v', 'attached_pic', - outputName - ]; - } else { - args = [ - '-i', inputName, - ...metadata, - '-c:a', 'copy', - outputName - ]; - } - - console.log('[Metadata] Executing FFmpeg...'); - - if (onProgress) { - this.ffmpeg.on('progress', ({ progress }) => { - onProgress(progress); - }); - } - - await this.ffmpeg.exec(args); - console.log('[Metadata] FFmpeg exec completed successfully'); - - const outputData = await this.ffmpeg.readFile(outputName); - const outputBlob = new Blob([outputData], { type: 'audio/flac' }); - console.log('[Metadata] ✓ Success! Input:', arrayBuffer.byteLength, 'bytes → Output:', outputBlob.size, 'bytes'); - - await this.ffmpeg.deleteFile(inputName); - await this.ffmpeg.deleteFile(outputName); - if (hasCover) { - await this.ffmpeg.deleteFile(coverName); - } - console.log('[Metadata] Cleanup complete'); - - return outputBlob; - } catch (error) { - console.error('[Metadata] ✗ Embedding failed:', error); - console.error('[Metadata] Error details:', { - name: error.name, - message: error.message, - stack: error.stack - }); - return audioBlob; - } - } - - buildMetadataArgs(track) { - const args = []; - - if (track.title) { - args.push('-metadata', `title=${this.escapeMetadata(track.title)}`); - } - - if (track.artist?.name) { - args.push('-metadata', `artist=${this.escapeMetadata(track.artist.name)}`); - } - - if (track.album?.title) { - args.push('-metadata', `album=${this.escapeMetadata(track.album.title)}`); - } - - if (track.album?.artist?.name) { - args.push('-metadata', `album_artist=${this.escapeMetadata(track.album.artist.name)}`); - } - - if (track.trackNumber) { - const trackNum = Number(track.trackNumber); - if (Number.isFinite(trackNum) && trackNum > 0) { - const totalTracks = track.album?.numberOfTracks; - if (totalTracks && Number.isFinite(totalTracks) && totalTracks > 0) { - args.push('-metadata', `track=${trackNum}/${totalTracks}`); - } else { - args.push('-metadata', `track=${trackNum}`); - } - } - } - - if (track.volumeNumber) { - const discNum = Number(track.volumeNumber); - if (Number.isFinite(discNum) && discNum > 0) { - const totalDiscs = track.album?.numberOfVolumes; - if (totalDiscs && Number.isFinite(totalDiscs) && totalDiscs > 0) { - args.push('-metadata', `disc=${discNum}/${totalDiscs}`); - } else { - args.push('-metadata', `disc=${discNum}`); - } - } - } - - if (track.album?.releaseDate) { - const year = new Date(track.album.releaseDate).getFullYear(); - if (!isNaN(year)) { - args.push('-metadata', `date=${year}`); - args.push('-metadata', `year=${year}`); - } - } - - if (track.album?.upc) { - args.push('-metadata', `barcode=${track.album.upc}`); - } - - if (track.isrc) { - args.push('-metadata', `isrc=${track.isrc}`); - } - - args.push('-metadata', 'comment=https://monochrome.tf/'); - - return args; - } - - escapeMetadata(value) { - return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - } -} \ No newline at end of file diff --git a/js/storage.js b/js/storage.js index 7ced455..e148988 100644 --- a/js/storage.js +++ b/js/storage.js @@ -276,4 +276,20 @@ export const themeManager = { root.style.setProperty(`--${key}`, value); } } +}; + +export const lastFMStorage = { + STORAGE_KEY: 'lastfm-enabled', + + isEnabled() { + try { + return localStorage.getItem(this.STORAGE_KEY) === 'true'; + } catch (e) { + return false; + } + }, + + setEnabled(enabled) { + localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); + } }; \ No newline at end of file diff --git a/js/utils.js b/js/utils.js index efc61a6..05754a2 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,3 +1,4 @@ +//utils.js export const QUALITY = 'LOSSLESS'; export const REPEAT_MODE = { diff --git a/manifest.json b/manifest.json index a016477..0a211d4 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,10 @@ "purpose": "maskable" } ], - "categories": ["music", "entertainment"], + "categories": [ + "music", + "entertainment" + ], "shortcuts": [ { "name": "Search", diff --git a/styles.css b/styles.css index f19e7ef..c4ac053 100644 --- a/styles.css +++ b/styles.css @@ -1748,4 +1748,18 @@ input:checked + .slider:before { padding: var(--spacing-sm) var(--spacing-md); font-size: 0.9rem; } +} +.btn-secondary.danger { + background: #ef4444; + color: white; +} + +.btn-secondary.danger:hover { + background: #dc2626; +} + +#lastfm-controls { + display: flex; + align-items: center; + gap: 0.5rem; } \ No newline at end of file From 2c04d69edb5739eefac45a63d165343808634c59 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 21 Oct 2025 18:32:57 +0300 Subject: [PATCH 08/11] not popup --- js/api.js | 1 + js/app.js | 3 ++- js/cache.js | 1 + js/player.js | 1 + js/ui.js | 1 + 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/js/api.js b/js/api.js index aa45218..2d809d5 100644 --- a/js/api.js +++ b/js/api.js @@ -1,3 +1,4 @@ +//api.js import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js'; import { APICache } from './cache.js'; diff --git a/js/app.js b/js/app.js index 021a203..07d56cc 100644 --- a/js/app.js +++ b/js/app.js @@ -1,3 +1,4 @@ +//app.js import { LosslessAPI } from './api.js'; import { apiSettings, themeManager, lastFMStorage } from './storage.js'; import { UIRenderer } from './ui.js'; @@ -409,7 +410,7 @@ document.addEventListener('DOMContentLoaded', async () => { const { token, url } = await scrobbler.getAuthUrl(); - const authWindow = window.open(url, 'lastfm-auth', 'width=800,height=600'); + const authWindow = window.open(url, '_blank'); lastfmConnectBtn.textContent = 'Waiting for authorization...'; diff --git a/js/cache.js b/js/cache.js index a12d00a..61f3341 100644 --- a/js/cache.js +++ b/js/cache.js @@ -1,3 +1,4 @@ +//cache.js export class APICache { constructor(options = {}) { this.memoryCache = new Map(); diff --git a/js/player.js b/js/player.js index 5459478..67a1e6b 100644 --- a/js/player.js +++ b/js/player.js @@ -1,3 +1,4 @@ +//player.js import { REPEAT_MODE, formatTime } from './utils.js'; export class Player { diff --git a/js/ui.js b/js/ui.js index e44f0cd..2a11137 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,3 +1,4 @@ +//ui.js import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent } from './utils.js'; import { recentActivityManager } from './storage.js'; From aca4aadf91d43328fb320c3b2861a49da49ece6d Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 21 Oct 2025 18:49:51 +0300 Subject: [PATCH 09/11] Update app.js --- js/app.js | 118 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/js/app.js b/js/app.js index 07d56cc..d477d0a 100644 --- a/js/app.js +++ b/js/app.js @@ -397,63 +397,75 @@ document.addEventListener('DOMContentLoaded', async () => { updateLastFMUI(); - lastfmConnectBtn?.addEventListener('click', async () => { - if (scrobbler.isAuthenticated()) { - if (confirm('Disconnect from Last.fm?')) { - scrobbler.disconnect(); - 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 { - try { - lastfmConnectBtn.disabled = true; - lastfmConnectBtn.textContent = 'Opening Last.fm...'; - - const { token, url } = await scrobbler.getAuthUrl(); - - const authWindow = window.open(url, '_blank'); - - lastfmConnectBtn.textContent = 'Waiting for authorization...'; - - let attempts = 0; - const maxAttempts = 30; - - const checkAuth = setInterval(async () => { - attempts++; - - if (attempts > maxAttempts) { - clearInterval(checkAuth); - lastfmConnectBtn.textContent = 'Connect Last.fm'; - lastfmConnectBtn.disabled = false; - alert('Authorization timed out. Please try again.'); - return; - } - - 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); + alert('Popup blocked! Please allow popups.'); + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.disabled = false; + return; + } + + lastfmConnectBtn.textContent = 'Waiting for authorization...'; + + let attempts = 0; + const maxAttempts = 30; + + const checkAuth = setInterval(async () => { + attempts++; + + if (attempts > maxAttempts) { + clearInterval(checkAuth); 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); + + 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); From 5be3311133520e8f6e06c64d8cbd5559ea351312 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 22 Oct 2025 09:23:29 +0300 Subject: [PATCH 10/11] Update index.html --- index.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index d000bea..fab5a2e 100644 --- a/index.html +++ b/index.html @@ -390,7 +390,9 @@
- + + + - \ No newline at end of file + From 0c9dec35ff3c6753f12bde27106075426bed88d5 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 22 Oct 2025 10:31:45 +0300 Subject: [PATCH 11/11] 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; + } +} +