From 97e41b521b8117bd8066156da33337baf62ae25c Mon Sep 17 00:00:00 2001 From: Kaler Date: Tue, 10 Mar 2026 13:28:16 +0530 Subject: [PATCH 01/21] Fixed lcd-visualiser CSS --- js/visualizers/lcd.js | 3 ++- styles.css | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/js/visualizers/lcd.js b/js/visualizers/lcd.js index 26fc3ec..6734481 100644 --- a/js/visualizers/lcd.js +++ b/js/visualizers/lcd.js @@ -269,7 +269,8 @@ export class LCDPreset { this.initWebGL(width, height); // Attach WebGL canvas to same parent as main canvas if (this.glCanvas && canvas.parentElement) { - canvas.parentElement.style.position = 'relative'; + //This position:relative was causing the visual bugs and problems in the lcd visualiser. + // canvas.parentElement.style.position = 'relative'; canvas.parentElement.appendChild(this.glCanvas); } } diff --git a/styles.css b/styles.css index dfc3a65..6d6f18b 100644 --- a/styles.css +++ b/styles.css @@ -7508,7 +7508,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { /* EQ Response Curve Canvas */ .eq-response-canvas { - position: absolute; + position: fixed; top: var(--spacing-md); left: 4px; width: calc(100% - 8px); From b75245648d50851c2e6e752da4bbe8f807892db8 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 07:02:56 +0300 Subject: [PATCH 02/21] fix(playlists): queue loading in chunks for large playlists --- js/side-panel.js | 6 +- js/ui-interactions.js | 349 ++++++++++++++++++++++++++---------------- 2 files changed, 223 insertions(+), 132 deletions(-) diff --git a/js/side-panel.js b/js/side-panel.js index 8ba2527..323e85f 100644 --- a/js/side-panel.js +++ b/js/side-panel.js @@ -61,14 +61,16 @@ export class SidePanelManager { return this.currentView === view && this.panel.classList.contains('active'); } - refresh(view, renderControlsCallback, renderContentCallback) { + refresh(view, renderControlsCallback, renderContentCallback, options = {}) { if (this.isActive(view)) { if (renderControlsCallback) { this.controlsElement.innerHTML = ''; renderControlsCallback(this.controlsElement); } if (renderContentCallback) { - this.contentElement.innerHTML = ''; + if (!options.noClear) { + this.contentElement.innerHTML = ''; + } renderContentCallback(this.contentElement); } } diff --git a/js/ui-interactions.js b/js/ui-interactions.js index 95cd7bc..6ae0213 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -75,6 +75,15 @@ export function initializeUIInteractions(player, api, ui) { } let draggedQueueIndex = null; + let queueStartIndex = 0; + let queueEndIndex = 1000; + let isQueueRendering = false; + let topObserver = null; + let bottomObserver = null; + const QUEUE_VIRTUALIZATION_THRESHOLD = 1500; + const QUEUE_MAX_RENDERED = 1000; + const QUEUE_CHUNK_SIZE = 200; + const ESTIMATED_ITEM_HEIGHT = 58; // Sidebar mobile hamburgerBtn.addEventListener('click', () => { @@ -232,66 +241,221 @@ export function initializeUIInteractions(player, api, ui) { } }; - const renderQueueContent = (container) => { + const renderQueueItemHTML = (track, index) => { + const isPlaying = index === player.currentQueueIndex; + const isBlocked = contentBlockingSettings?.shouldHideTrack(track); + const trackTitle = getTrackTitle(track); + const trackArtists = getTrackArtists(track, { fallback: 'Unknown' }); + const qualityBadge = createQualityBadgeHTML(track); + const blockedTitle = isBlocked + ? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"` + : ''; + + const isVideo = track.type === 'video'; + const coverUrl = + isVideo && track.imageId + ? api.getVideoCoverUrl(track.imageId) + : api.getCoverUrl(track.album?.cover); + + return ` +
+
+ + + + +
+
+ +
+
${escapeHtml(trackTitle)} ${qualityBadge}
+
${escapeHtml(trackArtists)}
+
+
+
${isBlocked ? '--:--' : formatTime(track.duration)}
+ + +
+ `; + }; + + const attachQueueListeners = (container) => { + if (container._queueListenersAttached) return; + + container.addEventListener('click', async (e) => { + const item = e.target.closest('.queue-track-item'); + if (!item) return; + + const index = parseInt(item.dataset.queueIndex); + const removeBtn = e.target.closest('.queue-remove-btn'); + if (removeBtn) { + e.stopPropagation(); + player.removeFromQueue(index); + refreshQueuePanel(); + return; + } + + const likeBtn = e.target.closest('.queue-like-btn'); + if (likeBtn && likeBtn.dataset.action === 'toggle-like') { + e.stopPropagation(); + const track = player.getCurrentQueue()[index]; + if (track) { + const added = await db.toggleFavorite('track', track); + syncManager.syncLibraryItem('track', track, added); + + likeBtn.classList.toggle('active', added); + likeBtn.innerHTML = added + ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') + : SVG_HEART; + + showNotification( + added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}` + ); + } + return; + } + + if (item.classList.contains('blocked')) return; + + player.playAtIndex(index); + refreshQueuePanel(); + }); + + container.addEventListener('contextmenu', async (e) => { + const item = e.target.closest('.queue-track-item'); + if (!item) return; + + e.preventDefault(); + const index = parseInt(item.dataset.queueIndex); + const contextMenu = document.getElementById('context-menu'); + if (contextMenu) { + const track = player.getCurrentQueue()[index]; + if (track) { + const isLiked = await db.isFavorite('track', track.id); + const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]'); + if (likeItem) { + likeItem.textContent = isLiked ? 'Unlike' : 'Like'; + } + + const trackMixItem = contextMenu.querySelector('li[data-action="track-mix"]'); + if (trackMixItem) { + const hasMix = track.mixes && track.mixes.TRACK_MIX; + trackMixItem.style.display = hasMix ? 'block' : 'none'; + } + + positionMenu(contextMenu, e.clientX, e.clientY); + contextMenu._contextTrack = track; + } + } + }); + + container.addEventListener('dragstart', (e) => { + const item = e.target.closest('.queue-track-item'); + if (item) { + draggedQueueIndex = parseInt(item.dataset.queueIndex); + item.style.opacity = '0.5'; + } + }); + + container.addEventListener('dragend', (e) => { + const item = e.target.closest('.queue-track-item'); + if (item) { + item.style.opacity = '1'; + } + }); + + container.addEventListener('dragover', (e) => { + e.preventDefault(); + }); + + container.addEventListener('drop', (e) => { + e.preventDefault(); + const item = e.target.closest('.queue-track-item'); + if (item && draggedQueueIndex !== null) { + const index = parseInt(item.dataset.queueIndex); + if (draggedQueueIndex !== index) { + player.moveInQueue(draggedQueueIndex, index); + refreshQueuePanel(); + } + } + }); + + container._queueListenersAttached = true; + }; + + const renderQueueContent = (container, isUpdate = false) => { const currentQueue = player.getCurrentQueue(); if (currentQueue.length === 0) { container.innerHTML = '
Queue is empty.
'; + queueStartIndex = 0; + queueEndIndex = QUEUE_MAX_RENDERED; return; } - const html = currentQueue - .map((track, index) => { - const isPlaying = index === player.currentQueueIndex; - const isBlocked = contentBlockingSettings?.shouldHideTrack(track); - const trackTitle = getTrackTitle(track); - const trackArtists = getTrackArtists(track, { fallback: 'Unknown' }); - const qualityBadge = createQualityBadgeHTML(track); - const blockedTitle = isBlocked - ? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"` - : ''; + isQueueRendering = true; + attachQueueListeners(container); - const isVideo = track.type === 'video'; - const coverUrl = - isVideo && track.imageId - ? api.getVideoCoverUrl(track.imageId) - : api.getCoverUrl(track.album?.cover); + if (currentQueue.length > QUEUE_VIRTUALIZATION_THRESHOLD) { + if (!isUpdate) { + const currentIndex = player.currentQueueIndex || 0; + queueStartIndex = Math.max(0, Math.floor((currentIndex - QUEUE_MAX_RENDERED / 2) / 100) * 100); + queueEndIndex = Math.min(currentQueue.length, queueStartIndex + QUEUE_MAX_RENDERED); + } - return ` -
-
- - - - + const visibleTracks = currentQueue.slice(queueStartIndex, queueEndIndex); + const topSpacerHeight = queueStartIndex * ESTIMATED_ITEM_HEIGHT; + const bottomSpacerHeight = (currentQueue.length - queueEndIndex) * ESTIMATED_ITEM_HEIGHT; + + container.innerHTML = ` +
+
+
+ ${visibleTracks.map((track, i) => renderQueueItemHTML(track, queueStartIndex + i)).join('')}
-
- -
-
${escapeHtml(trackTitle)} ${qualityBadge}
-
${escapeHtml(trackArtists)}
-
-
-
${isBlocked ? '--:--' : formatTime(track.duration)}
- - +
`; - }) - .join(''); - container.innerHTML = html; + if (topObserver) topObserver.disconnect(); + if (bottomObserver) bottomObserver.disconnect(); + + bottomObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) { + queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE); + if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { + queueStartIndex += QUEUE_CHUNK_SIZE; + } + renderQueueContent(container, true); + } + }, { root: container, rootMargin: '200px' }); + + topObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) { + queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE); + if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { + queueEndIndex -= QUEUE_CHUNK_SIZE; + } + renderQueueContent(container, true); + } + }, { root: container, rootMargin: '200px' }); + + topObserver.observe(container.querySelector('#queue-top-sentinel')); + bottomObserver.observe(container.querySelector('#queue-bottom-sentinel')); + } else { + container.innerHTML = `
${currentQueue.map((track, index) => renderQueueItemHTML(track, index)).join('')}
`; + if (topObserver) topObserver.disconnect(); + if (bottomObserver) bottomObserver.disconnect(); + } container.querySelectorAll('.queue-track-item').forEach(async (item) => { const index = parseInt(item.dataset.queueIndex); - const track = player.getCurrentQueue()[index]; - - // Update like button state + const track = currentQueue[index]; const likeBtn = item.querySelector('.queue-like-btn'); if (likeBtn && track) { const isLiked = await db.isFavorite('track', track.id); @@ -300,101 +464,26 @@ export function initializeUIInteractions(player, api, ui) { ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') : SVG_HEART; } - - item.addEventListener('click', async (e) => { - const removeBtn = e.target.closest('.queue-remove-btn'); - if (removeBtn) { - e.stopPropagation(); - player.removeFromQueue(index); - refreshQueuePanel(); - return; - } - - const likeBtn = e.target.closest('.queue-like-btn'); - if (likeBtn && likeBtn.dataset.action === 'toggle-like') { - e.stopPropagation(); - const track = player.getCurrentQueue()[index]; - if (track) { - const added = await db.toggleFavorite('track', track); - syncManager.syncLibraryItem('track', track, added); - - // Update button state - likeBtn.classList.toggle('active', added); - likeBtn.innerHTML = added - ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') - : SVG_HEART; - - showNotification( - added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}` - ); - } - return; - } - - // Don't play blocked tracks - if (item.classList.contains('blocked')) { - return; - } - - player.playAtIndex(index); - refreshQueuePanel(); - }); - - item.addEventListener('contextmenu', async (e) => { - e.preventDefault(); - const contextMenu = document.getElementById('context-menu'); - if (contextMenu) { - const track = player.getCurrentQueue()[index]; - if (track) { - const isLiked = await db.isFavorite('track', track.id); - const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]'); - if (likeItem) { - likeItem.textContent = isLiked ? 'Unlike' : 'Like'; - } - - const trackMixItem = contextMenu.querySelector('li[data-action="track-mix"]'); - if (trackMixItem) { - const hasMix = track.mixes && track.mixes.TRACK_MIX; - trackMixItem.style.display = hasMix ? 'block' : 'none'; - } - - positionMenu(contextMenu, e.clientX, e.clientY); - - contextMenu._contextTrack = track; - } - } - }); - - item.addEventListener('dragstart', () => { - draggedQueueIndex = index; - item.style.opacity = '0.5'; - }); - - item.addEventListener('dragend', () => { - item.style.opacity = '1'; - }); - - item.addEventListener('dragover', (e) => { - e.preventDefault(); - }); - - item.addEventListener('drop', (e) => { - e.preventDefault(); - if (draggedQueueIndex !== null && draggedQueueIndex !== index) { - player.moveInQueue(draggedQueueIndex, index); - refreshQueuePanel(); - } - }); }); + + isQueueRendering = false; }; const refreshQueuePanel = () => { - sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent); + sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true }); }; const openQueuePanel = () => { trackOpenQueue(); sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent); + + setTimeout(() => { + const container = document.getElementById('side-panel-content'); + const playingItem = container?.querySelector('.queue-track-item.playing'); + if (playingItem) { + playingItem.scrollIntoView({ block: 'center', behavior: 'auto' }); + } + }, 100); }; queueBtn.addEventListener('click', openQueuePanel); From 0641e3da33b7c43cfab1f99a6a47b88cbae76e90 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:03:24 +0000 Subject: [PATCH 03/21] style: auto-fix linting issues --- js/ui-interactions.js | 48 ++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/js/ui-interactions.js b/js/ui-interactions.js index 6ae0213..59f2e06 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -253,9 +253,7 @@ export function initializeUIInteractions(player, api, ui) { const isVideo = track.type === 'video'; const coverUrl = - isVideo && track.imageId - ? api.getVideoCoverUrl(track.imageId) - : api.getCoverUrl(track.album?.cover); + isVideo && track.imageId ? api.getVideoCoverUrl(track.imageId) : api.getCoverUrl(track.album?.cover); return `
@@ -313,9 +311,7 @@ export function initializeUIInteractions(player, api, ui) { ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') : SVG_HEART; - showNotification( - added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}` - ); + showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`); } return; } @@ -425,25 +421,31 @@ export function initializeUIInteractions(player, api, ui) { if (topObserver) topObserver.disconnect(); if (bottomObserver) bottomObserver.disconnect(); - bottomObserver = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) { - queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE); - if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { - queueStartIndex += QUEUE_CHUNK_SIZE; + bottomObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) { + queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE); + if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { + queueStartIndex += QUEUE_CHUNK_SIZE; + } + renderQueueContent(container, true); } - renderQueueContent(container, true); - } - }, { root: container, rootMargin: '200px' }); + }, + { root: container, rootMargin: '200px' } + ); - topObserver = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) { - queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE); - if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { - queueEndIndex -= QUEUE_CHUNK_SIZE; + topObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) { + queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE); + if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { + queueEndIndex -= QUEUE_CHUNK_SIZE; + } + renderQueueContent(container, true); } - renderQueueContent(container, true); - } - }, { root: container, rootMargin: '200px' }); + }, + { root: container, rootMargin: '200px' } + ); topObserver.observe(container.querySelector('#queue-top-sentinel')); bottomObserver.observe(container.querySelector('#queue-bottom-sentinel')); @@ -465,7 +467,7 @@ export function initializeUIInteractions(player, api, ui) { : SVG_HEART; } }); - + isQueueRendering = false; }; From 71a2c5be9373121c87e62a25e47de0d99d0e026a Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 08:23:27 +0300 Subject: [PATCH 04/21] make le diddy infinite radio better kinda --- js/api.js | 25 ++++++++++++----------- js/player.js | 56 +++++++++++++++++++++++++++------------------------- js/ui.js | 16 +++++++++++++-- 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/js/api.js b/js/api.js index 8972969..201bcff 100644 --- a/js/api.js +++ b/js/api.js @@ -1097,13 +1097,8 @@ export class LosslessAPI { const recommendedTracks = []; const seenTrackIds = new Set(tracks.map((t) => t.id)); - // Shuffle artists if refreshing to get different results - let shuffledArtists = artists; - if (options.refresh) { - shuffledArtists = [...artists].sort(() => Math.random() - 0.5); - } - - const artistsToProcess = shuffledArtists.slice(0, Math.min(5, shuffledArtists.length)); + const shuffledArtists = [...artists].sort(() => Math.random() - 0.5); + const artistsToProcess = shuffledArtists.slice(0, Math.min(15, shuffledArtists.length)); const artistPromises = artistsToProcess.map(async (artist) => { try { @@ -1111,11 +1106,19 @@ export class LosslessAPI { const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh }); if (artistData && artistData.tracks && artistData.tracks.length > 0) { const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)); - // Shuffle and pick different tracks when refreshing - const shuffled = options.refresh - ? availableTracks.sort(() => Math.random() - 0.5) + + const newTracks = options.knownTrackIds + ? availableTracks.filter(t => !options.knownTrackIds.has(t.id)) : availableTracks; - return shuffled.slice(0, 4); + const knownTracks = options.knownTrackIds + ? availableTracks.filter(t => options.knownTrackIds.has(t.id)) + : []; + + const shuffledNew = [...newTracks].sort(() => Math.random() - 0.5); + const shuffledKnown = [...knownTracks].sort(() => Math.random() - 0.5); + + const combined = [...shuffledNew, ...shuffledKnown]; + return combined.slice(0, 2); } else { console.warn(`No tracks found for artist ${artist.name}`); return []; diff --git a/js/player.js b/js/player.js index 3701eff..2cc977d 100644 --- a/js/player.js +++ b/js/player.js @@ -984,13 +984,15 @@ export class Player { const pickedSeeds = await this.pickRadioSeeds(); if (pickedSeeds.length > 0) { this.radioSeeds = pickedSeeds; - this.setQueue(pickedSeeds, 0, true); + const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5); + this.setQueue(initialQueue, 0, true); this.playAtIndex(0); } } else { this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds]; this.wipeQueue(); - this.setQueue(this.radioSeeds, 0, true); + const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds]; + this.setQueue(initialQueue, 0, true); this.playAtIndex(0); } @@ -1021,41 +1023,41 @@ export class Player { this.radioSeeds = await this.pickRadioSeeds(); } - const seeds = - this.radioSeeds.length > 0 ? this.radioSeeds : this.currentTrack ? [this.currentTrack] : []; + const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); + const seeds = shuffledSeeds.length > 0 + ? shuffledSeeds.slice(0, 5) + : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; } - const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 10); + const [favorites, userPlaylists, history] = await Promise.all([ + db.getFavorites('track'), + db.getAll('user_playlists'), + db.getHistory(), + ]); + + const knownTrackIds = new Set([ + ...favorites.map((t) => t.id), + ...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)), + ...history.map((t) => t.id), + ]); + + const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { + knownTrackIds: knownTrackIds + }); + if (recommendations && recommendations.length > 0) { const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id)); - const [favorites, userPlaylists, history] = await Promise.all([ - db.getFavorites('track'), - db.getAll('user_playlists'), - db.getHistory(), - ]); - - const knownTrackIds = new Set([ - ...favorites.map((t) => t.id), - ...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)), - ...history.map((t) => t.id), - ]); - - const newTracks = recommendations.filter((t) => { - if (currentQueueIds.has(t.id)) return false; - - if (knownTrackIds.has(t.id)) { - return Math.random() < 0.05; - } - - return true; + let newTracks = recommendations.filter((t) => { + return !currentQueueIds.has(t.id); }); if (newTracks.length > 0) { - this.addToQueue(newTracks); + const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5); + this.addToQueue(tracksToAdd); } } } catch (error) { @@ -1112,7 +1114,7 @@ export class Player { potentialSeeds.find((s) => s.id === id) ); - return uniqueSeeds.sort(() => 0.5 - Math.random()).slice(0, 5); + return uniqueSeeds.sort(() => 0.5 - Math.random()).slice(0, 50); } catch (error) { console.error('Failed to pick radio seeds:', error); return this.currentTrack ? [this.currentTrack] : []; diff --git a/js/ui.js b/js/ui.js index 99d0b55..6f90acb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -2188,9 +2188,21 @@ export class UIRenderer { try { const seeds = providedSeeds || (await this.getSeeds()); - const trackSeeds = seeds.slice(0, 5); - const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(trackSeeds, 20, { + + const [favorites, playlists, history] = await Promise.all([ + db.getFavorites('track'), + db.getPlaylists(true), + db.getHistory(), + ]); + const knownTrackIds = new Set([ + ...favorites.map(t => t.id), + ...playlists.flatMap(p => (p.tracks || []).map(t => t.id)), + ...history.map(t => t.id) + ]); + + const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { skipCache: forceRefresh, + knownTrackIds: knownTrackIds }); const filteredTracks = await this.filterUserContent(recommendedTracks, 'track'); From 12baaaa55f27ab924e82d7a94ca8bfdede4d3221 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 05:23:57 +0000 Subject: [PATCH 05/21] style: auto-fix linting issues --- js/api.js | 10 +++++----- js/player.js | 7 +++---- js/ui.js | 10 +++++----- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/js/api.js b/js/api.js index 201bcff..fd56dc2 100644 --- a/js/api.js +++ b/js/api.js @@ -1106,12 +1106,12 @@ export class LosslessAPI { const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh }); if (artistData && artistData.tracks && artistData.tracks.length > 0) { const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)); - - const newTracks = options.knownTrackIds - ? availableTracks.filter(t => !options.knownTrackIds.has(t.id)) + + const newTracks = options.knownTrackIds + ? availableTracks.filter((t) => !options.knownTrackIds.has(t.id)) : availableTracks; - const knownTracks = options.knownTrackIds - ? availableTracks.filter(t => options.knownTrackIds.has(t.id)) + const knownTracks = options.knownTrackIds + ? availableTracks.filter((t) => options.knownTrackIds.has(t.id)) : []; const shuffledNew = [...newTracks].sort(() => Math.random() - 0.5); diff --git a/js/player.js b/js/player.js index 2cc977d..040a34a 100644 --- a/js/player.js +++ b/js/player.js @@ -1024,9 +1024,8 @@ export class Player { } const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); - const seeds = shuffledSeeds.length > 0 - ? shuffledSeeds.slice(0, 5) - : this.currentTrack ? [this.currentTrack] : []; + const seeds = + shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; @@ -1045,7 +1044,7 @@ export class Player { ]); const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { - knownTrackIds: knownTrackIds + knownTrackIds: knownTrackIds, }); if (recommendations && recommendations.length > 0) { diff --git a/js/ui.js b/js/ui.js index 6f90acb..dbf3225 100644 --- a/js/ui.js +++ b/js/ui.js @@ -2188,21 +2188,21 @@ export class UIRenderer { try { const seeds = providedSeeds || (await this.getSeeds()); - + const [favorites, playlists, history] = await Promise.all([ db.getFavorites('track'), db.getPlaylists(true), db.getHistory(), ]); const knownTrackIds = new Set([ - ...favorites.map(t => t.id), - ...playlists.flatMap(p => (p.tracks || []).map(t => t.id)), - ...history.map(t => t.id) + ...favorites.map((t) => t.id), + ...playlists.flatMap((p) => (p.tracks || []).map((t) => t.id)), + ...history.map((t) => t.id), ]); const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { skipCache: forceRefresh, - knownTrackIds: knownTrackIds + knownTrackIds: knownTrackIds, }); const filteredTracks = await this.filterUserContent(recommendedTracks, 'track'); From 07003f92f0da137c09227ecfd846363e46f2c326 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 08:36:11 +0300 Subject: [PATCH 06/21] fix toggle UI console errors + volume button disappearing for no reason --- js/events.js | 17 +++++++++++++++++ js/ui.js | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/js/events.js b/js/events.js index 03d0820..c3a27ce 100644 --- a/js/events.js +++ b/js/events.js @@ -384,6 +384,23 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { updateWaveform(); }); + if (volumeBtn) { + volumeBtn.addEventListener('click', () => { + const activeEl = player.activeElement; + activeEl.muted = !activeEl.muted; + localStorage.setItem('muted', activeEl.muted); + + const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video; + if (inactiveEl) inactiveEl.muted = activeEl.muted; + + updateVolumeUI(); + }); + } + const isMuted = localStorage.getItem('muted') === 'true'; + audioPlayer.muted = isMuted; + if (player.video) player.video.muted = isMuted; + updateVolumeUI(); + initializeSmoothSliders(player); } diff --git a/js/ui.js b/js/ui.js index 6f90acb..a6f2cea 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1242,6 +1242,20 @@ export class UIRenderer { showButton(); } + const toggleUI = (e) => { + if (e) e.stopPropagation(); + isUIHidden = !isUIHidden; + overlay.classList.toggle('ui-hidden', isUIHidden); + toggleBtn.classList.toggle('active', isUIHidden); + toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI'; + + if (isUIHidden) { + hideButton(); + } else { + showButton(); + } + }; + // Mouse move handler const handleMouseMove = (e) => { const rect = overlay.getBoundingClientRect(); From 17964a8bc95f7a2e28b354c34b26891f8d5a9013 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 09:13:28 +0300 Subject: [PATCH 07/21] fix media playback errors in firefox --- index.html | 2 +- js/player.js | 65 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index 5a253c3..2b72aec 100644 --- a/index.html +++ b/index.html @@ -2222,7 +2222,7 @@ 0) { - this.dashPlayer.seek(startTime); + if (startTime > 0) { + this.dashPlayer.seek(startTime); + } + + const canPlay = await this.waitForCanPlayOrTimeout(activeElement); + if (!canPlay || this.playbackSequence !== currentSequence) return; + await this.safePlay(activeElement); + } catch (e) { + console.error('DashPlayer initialize failed for audio:', e); + throw new Error('DASH initialization failed'); } + } else if (streamUrl && (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl'))) { + this.setupHlsVideo(activeElement, streamUrl, null); + this.applyAudioEffects(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement); if (!canPlay || this.playbackSequence !== currentSequence) return; + + if (startTime > 0) { + activeElement.currentTime = startTime; + } await this.safePlay(activeElement); - } else { + } else if (streamUrl) { activeElement.src = streamUrl; this.applyAudioEffects(); @@ -885,6 +922,8 @@ export class Player { } const played = await this.safePlay(activeElement); if (!played) return; + } else { + throw new Error('Could not resolve stream URL'); } } From 3c51d45d7477e4f028e362807932b628b449da16 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:14:00 +0000 Subject: [PATCH 08/21] style: auto-fix linting issues --- js/player.js | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/js/player.js b/js/player.js index 57e3b1f..f787274 100644 --- a/js/player.js +++ b/js/player.js @@ -109,19 +109,23 @@ export class Player { this._setupVideoSync(); } -_resetDashPlayer() { - if (this.dashInitialized && this.dashPlayer) { - try { this.dashPlayer.attachView(null); } catch (e) {} - try { this.dashPlayer.destroy(); } catch (e) {} - this.dashPlayer = MediaPlayer().create(); - this.dashPlayer.updateSettings({ - streaming: { - buffer: { fastSwitchEnabled: true }, - }, - }); - this.dashInitialized = false; + _resetDashPlayer() { + if (this.dashInitialized && this.dashPlayer) { + try { + this.dashPlayer.attachView(null); + } catch (e) {} + try { + this.dashPlayer.destroy(); + } catch (e) {} + this.dashPlayer = MediaPlayer().create(); + this.dashPlayer.updateSettings({ + streaming: { + buffer: { fastSwitchEnabled: true }, + }, + }); + this.dashInitialized = false; + } } -} _setupVideoSync() { if (!this.video || !this.audio) return; @@ -898,7 +902,10 @@ _resetDashPlayer() { console.error('DashPlayer initialize failed for audio:', e); throw new Error('DASH initialization failed'); } - } else if (streamUrl && (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl'))) { + } else if ( + streamUrl && + (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) + ) { this.setupHlsVideo(activeElement, streamUrl, null); this.applyAudioEffects(); From cafb3ea7bbaca5c8b13118223381e949e0457b94 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 09:14:38 +0300 Subject: [PATCH 09/21] oh fuck i pushed this on aiccdent --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 2b72aec..5a253c3 100644 --- a/index.html +++ b/index.html @@ -2222,7 +2222,7 @@ Date: Wed, 11 Mar 2026 09:20:44 +0300 Subject: [PATCH 10/21] add top 5 album in editor picks --- public/editors-picks.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/public/editors-picks.json b/public/editors-picks.json index ce10ed3..bda9e3d 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -152,5 +152,16 @@ "explicit": false, "audioQuality": "LOSSLESS", "mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } + }, + { + "type": "album", + "id": "344201347", + "title": "Flex Musix (FLXTRA)", + "artist": { "id": 27836827, "name": "OsamaSon" }, + "releaseDate": "2024-02-16", + "cover": "5d1812fc-b9f9-4467-ac78-90d78ea542e4", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } } -] +] \ No newline at end of file From d0fff02e64f8bd405a5863e483591c1f0b260db9 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:21:15 +0000 Subject: [PATCH 11/21] style: auto-fix linting issues --- public/editors-picks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/editors-picks.json b/public/editors-picks.json index bda9e3d..808fa86 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -164,4 +164,4 @@ "audioQuality": "LOSSLESS", "mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } } -] \ No newline at end of file +] From 6fd334f74d003b7c41d3bfc0d2fb6604bb194723 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 09:28:03 +0300 Subject: [PATCH 12/21] PLEASE JUST FUCKING WORK WHAT --- js/player.js | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/js/player.js b/js/player.js index f787274..1f31739 100644 --- a/js/player.js +++ b/js/player.js @@ -109,23 +109,19 @@ export class Player { this._setupVideoSync(); } - _resetDashPlayer() { - if (this.dashInitialized && this.dashPlayer) { - try { - this.dashPlayer.attachView(null); - } catch (e) {} - try { - this.dashPlayer.destroy(); - } catch (e) {} - this.dashPlayer = MediaPlayer().create(); - this.dashPlayer.updateSettings({ - streaming: { - buffer: { fastSwitchEnabled: true }, - }, - }); - this.dashInitialized = false; - } +_resetDashPlayer() { + if (this.dashInitialized && this.dashPlayer) { + try { this.dashPlayer.attachView(null); } catch (e) {} + try { this.dashPlayer.destroy(); } catch (e) {} + this.dashPlayer = MediaPlayer().create(); + this.dashPlayer.updateSettings({ + streaming: { + buffer: { fastSwitchEnabled: true }, + }, + }); + this.dashInitialized = false; } +} _setupVideoSync() { if (!this.video || !this.audio) return; @@ -902,10 +898,7 @@ export class Player { console.error('DashPlayer initialize failed for audio:', e); throw new Error('DASH initialization failed'); } - } else if ( - streamUrl && - (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) - ) { + } else if (streamUrl && (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl'))) { this.setupHlsVideo(activeElement, streamUrl, null); this.applyAudioEffects(); @@ -1070,8 +1063,9 @@ export class Player { } const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); - const seeds = - shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : []; + const seeds = shuffledSeeds.length > 0 + ? shuffledSeeds.slice(0, 5) + : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; @@ -1090,7 +1084,7 @@ export class Player { ]); const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { - knownTrackIds: knownTrackIds, + knownTrackIds: knownTrackIds }); if (recommendations && recommendations.length > 0) { From a35b51f8cd18d6cd7bb7a30e1f38ba09e4e98137 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:28:37 +0000 Subject: [PATCH 13/21] style: auto-fix linting issues --- js/player.js | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/js/player.js b/js/player.js index 1f31739..f787274 100644 --- a/js/player.js +++ b/js/player.js @@ -109,19 +109,23 @@ export class Player { this._setupVideoSync(); } -_resetDashPlayer() { - if (this.dashInitialized && this.dashPlayer) { - try { this.dashPlayer.attachView(null); } catch (e) {} - try { this.dashPlayer.destroy(); } catch (e) {} - this.dashPlayer = MediaPlayer().create(); - this.dashPlayer.updateSettings({ - streaming: { - buffer: { fastSwitchEnabled: true }, - }, - }); - this.dashInitialized = false; + _resetDashPlayer() { + if (this.dashInitialized && this.dashPlayer) { + try { + this.dashPlayer.attachView(null); + } catch (e) {} + try { + this.dashPlayer.destroy(); + } catch (e) {} + this.dashPlayer = MediaPlayer().create(); + this.dashPlayer.updateSettings({ + streaming: { + buffer: { fastSwitchEnabled: true }, + }, + }); + this.dashInitialized = false; + } } -} _setupVideoSync() { if (!this.video || !this.audio) return; @@ -898,7 +902,10 @@ _resetDashPlayer() { console.error('DashPlayer initialize failed for audio:', e); throw new Error('DASH initialization failed'); } - } else if (streamUrl && (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl'))) { + } else if ( + streamUrl && + (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) + ) { this.setupHlsVideo(activeElement, streamUrl, null); this.applyAudioEffects(); @@ -1063,9 +1070,8 @@ _resetDashPlayer() { } const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); - const seeds = shuffledSeeds.length > 0 - ? shuffledSeeds.slice(0, 5) - : this.currentTrack ? [this.currentTrack] : []; + const seeds = + shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; @@ -1084,7 +1090,7 @@ _resetDashPlayer() { ]); const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { - knownTrackIds: knownTrackIds + knownTrackIds: knownTrackIds, }); if (recommendations && recommendations.length > 0) { From ffdcc1d3960c41e9865f60dcd0eaafc286df3a00 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 11 Mar 2026 10:11:38 +0300 Subject: [PATCH 14/21] THERE YOU SHITHEAD --- js/events.js | 12 ++++++ js/player.js | 106 ++++++++++++++++++++------------------------------- 2 files changed, 54 insertions(+), 64 deletions(-) diff --git a/js/events.js b/js/events.js index c3a27ce..e2c4e45 100644 --- a/js/events.js +++ b/js/events.js @@ -202,7 +202,19 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { console.error(`Media playback error (${element.id}):`, errorMsg, e); playPauseBtn.innerHTML = SVG_PLAY; + const canFallback = player.quality === 'HI_RES_LOSSLESS' && + errorMsg.includes('Source not supported') && + errorMsg.includes('0x80004005') && + !player.isFallbackRetry; + + if (canFallback) { + console.warn('Hi-Res failed due to DASH.js Error (FUCK DASH)'); + } + if (player.currentTrack && error && error.code !== 1) { + if (player.isFallbackInProgress || canFallback) { + return; + } console.warn('Skipping to next track due to playback error'); setTimeout(() => player.playNext(), 1000); } diff --git a/js/player.js b/js/player.js index f787274..2fb54a7 100644 --- a/js/player.js +++ b/js/player.js @@ -40,6 +40,7 @@ export class Player { this.currentRgValues = null; this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7'); this.isFallbackRetry = false; + this.isFallbackInProgress = false; this.autoplayBlocked = false; this.isIOS = typeof window !== 'undefined' && window.__IS_IOS__ === true; this.isPwa = @@ -69,9 +70,6 @@ export class Player { buffer: { fastSwitchEnabled: true, }, - protection: { - ignoreProtection: true, - }, }, }); this.dashInitialized = false; @@ -109,23 +107,6 @@ export class Player { this._setupVideoSync(); } - _resetDashPlayer() { - if (this.dashInitialized && this.dashPlayer) { - try { - this.dashPlayer.attachView(null); - } catch (e) {} - try { - this.dashPlayer.destroy(); - } catch (e) {} - this.dashPlayer = MediaPlayer().create(); - this.dashPlayer.updateSettings({ - streaming: { - buffer: { fastSwitchEnabled: true }, - }, - }); - this.dashInitialized = false; - } - } _setupVideoSync() { if (!this.video || !this.audio) return; @@ -600,7 +581,11 @@ export class Player { await this.playTrackFromQueue(); } - async playTrackFromQueue(startTime = 0, recursiveCount = 0) { + async playTrackFromQueue(startTime = 0, recursiveCount = 0, isRetry = false) { + if (!isRetry) { + this.isFallbackRetry = false; + } + const currentSequence = ++this.playbackSequence; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { @@ -640,7 +625,10 @@ export class Player { this.hls.destroy(); this.hls = null; } - this._resetDashPlayer(); + if (this.dashInitialized) { + this.dashPlayer.reset(); + this.dashInitialized = false; + } if (inactiveElement) { inactiveElement.pause(); @@ -816,13 +804,8 @@ export class Player { if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) { this.setupHlsVideo(activeElement, streamUrl, null); } else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) { - try { - this.dashPlayer.initialize(activeElement, streamUrl, false); - this.dashInitialized = true; - } catch (e) { - console.error('DashPlayer initialize failed for video:', e); - throw new Error('DASH initialization failed'); - } + this.dashPlayer.initialize(activeElement, streamUrl, false); + this.dashInitialized = true; } else { activeElement.src = streamUrl; } @@ -873,9 +856,6 @@ export class Player { streamUrl = trackData.originalTrackUrl; } else if (trackData.info?.manifest) { streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest); - if (!streamUrl) { - streamUrl = await this.api.getStreamUrl(track.id, this.quality); - } } else { streamUrl = await this.api.getStreamUrl(track.id, this.quality); } @@ -884,39 +864,20 @@ export class Player { if (this.playbackSequence !== currentSequence) return; // Handle playback - if (streamUrl && (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) && !track.isLocal) { + if (streamUrl && streamUrl.startsWith('blob:') && !track.isLocal) { // It's likely a DASH manifest blob URL - try { - this.dashPlayer.initialize(activeElement, streamUrl, false); - this.dashInitialized = true; - this.applyAudioEffects(); - - if (startTime > 0) { - this.dashPlayer.seek(startTime); - } - - const canPlay = await this.waitForCanPlayOrTimeout(activeElement); - if (!canPlay || this.playbackSequence !== currentSequence) return; - await this.safePlay(activeElement); - } catch (e) { - console.error('DashPlayer initialize failed for audio:', e); - throw new Error('DASH initialization failed'); - } - } else if ( - streamUrl && - (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) - ) { - this.setupHlsVideo(activeElement, streamUrl, null); + this.dashPlayer.initialize(activeElement, streamUrl, false); + this.dashInitialized = true; this.applyAudioEffects(); + if (startTime > 0) { + this.dashPlayer.seek(startTime); + } + const canPlay = await this.waitForCanPlayOrTimeout(activeElement); if (!canPlay || this.playbackSequence !== currentSequence) return; - - if (startTime > 0) { - activeElement.currentTime = startTime; - } await this.safePlay(activeElement); - } else if (streamUrl) { + } else { activeElement.src = streamUrl; this.applyAudioEffects(); @@ -929,8 +890,6 @@ export class Player { } const played = await this.safePlay(activeElement); if (!played) return; - } else { - throw new Error('Could not resolve stream URL'); } } @@ -941,6 +900,24 @@ export class Player { this.autoplayBlocked = true; return; } + + if (this.quality === 'HI_RES_LOSSLESS' && !this.isFallbackRetry) { + this.isFallbackRetry = true; + const originalQuality = this.quality; + this.quality = 'LOSSLESS'; + this.isFallbackInProgress = true; + try { + await this.playTrackFromQueue(startTime, recursiveCount, true); + return; + } catch (retryError) { + } finally { + this.quality = originalQuality; + this.isFallbackRetry = false; + this.isFallbackInProgress = false; + return; + } + } + console.error(`Could not play track: ${trackTitle}`, error); // Skip to next track on unexpected error if (recursiveCount < currentQueue.length) { @@ -1070,8 +1047,9 @@ export class Player { } const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); - const seeds = - shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : []; + const seeds = shuffledSeeds.length > 0 + ? shuffledSeeds.slice(0, 5) + : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; @@ -1090,7 +1068,7 @@ export class Player { ]); const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { - knownTrackIds: knownTrackIds, + knownTrackIds: knownTrackIds }); if (recommendations && recommendations.length > 0) { From 108387ac7adb35c168716ea32ccfe9572cca1e97 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:12:10 +0000 Subject: [PATCH 15/21] style: auto-fix linting issues --- js/events.js | 3 ++- js/player.js | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/js/events.js b/js/events.js index e2c4e45..29dbc81 100644 --- a/js/events.js +++ b/js/events.js @@ -202,7 +202,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { console.error(`Media playback error (${element.id}):`, errorMsg, e); playPauseBtn.innerHTML = SVG_PLAY; - const canFallback = player.quality === 'HI_RES_LOSSLESS' && + const canFallback = + player.quality === 'HI_RES_LOSSLESS' && errorMsg.includes('Source not supported') && errorMsg.includes('0x80004005') && !player.isFallbackRetry; diff --git a/js/player.js b/js/player.js index 2fb54a7..6f52b71 100644 --- a/js/player.js +++ b/js/player.js @@ -1047,9 +1047,8 @@ export class Player { } const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); - const seeds = shuffledSeeds.length > 0 - ? shuffledSeeds.slice(0, 5) - : this.currentTrack ? [this.currentTrack] : []; + const seeds = + shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; @@ -1068,7 +1067,7 @@ export class Player { ]); const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { - knownTrackIds: knownTrackIds + knownTrackIds: knownTrackIds, }); if (recommendations && recommendations.length > 0) { From bb44b7559d3170cfdc36d63526686fb00e43479b Mon Sep 17 00:00:00 2001 From: Kaler Date: Tue, 10 Mar 2026 13:57:47 +0530 Subject: [PATCH 16/21] fix(visualizer): correct LED layout and switching logic --- js/settings.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/settings.js b/js/settings.js index c52cb0c..773690c 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2274,6 +2274,9 @@ export function initializeSettings(scrobbler, player, api, ui) { ui.visualizer.setPreset(val); } updateButterchurnSettingsVisibility(); + + //Since changing the preset breaks the visualizer, a location.reload() is added to make sure that it works + window.location.reload() }); } From 84df59624e2530a0713c65e254f5ec6630fab6e1 Mon Sep 17 00:00:00 2001 From: edideaur <182119792+edideaur@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:43:15 +0000 Subject: [PATCH 17/21] style: auto-fix linting issues --- js/settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/settings.js b/js/settings.js index 773690c..3182372 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2276,7 +2276,7 @@ export function initializeSettings(scrobbler, player, api, ui) { updateButterchurnSettingsVisibility(); //Since changing the preset breaks the visualizer, a location.reload() is added to make sure that it works - window.location.reload() + window.location.reload(); }); } From 2a01fe3227df9b116a9341e4aa304b94c8c4ad5c Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:16:03 -0500 Subject: [PATCH 18/21] No change --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d739f5..30ea8da 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

Monochrome Logo From e1d7744ab2e9fcd5513b9f033b451f5e75673762 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:46:33 +0000 Subject: [PATCH 19/21] fix: correct total tracks per disc and add total discs to metadata for multi-disc albums --- js/api.js | 49 +++++++++++++++++++++++ js/downloads.js | 101 ++++++++++++++++++++++++++++++++++++++++++++---- js/metadata.js | 3 +- 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/js/api.js b/js/api.js index fd56dc2..30c3d9f 100644 --- a/js/api.js +++ b/js/api.js @@ -1489,6 +1489,55 @@ export class LosslessAPI { }; } + if ( + track.album?.id && + (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null) + ) { + try { + // Broad disc-field resolver — mirrors getExplicitTrackDiscNumber in downloads.js + const resolveDiscNumber = (t) => { + const candidates = [ + t.volumeNumber, + t.discNumber, + t.mediaNumber, + t.media_number, + t.volume, + t.disc, + t.disc_no, + t.discNo, + t.disc_number, + t.mediaMetadata?.discNumber, + ]; + for (const c of candidates) { + const parsed = parseInt(c, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 1; + }; + + const albumData = await this.getAlbum(track.album.id); + if (albumData.tracks?.length > 0) { + const discTrackCounts = new Map(); + let maxDiscNumber = 0; + for (const t of albumData.tracks) { + const dn = resolveDiscNumber(t); + discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1); + if (dn > maxDiscNumber) maxDiscNumber = dn; + } + const totalDiscs = maxDiscNumber || 1; + const discNumber = resolveDiscNumber(track); + enrichedTrack.album = { + ...(enrichedTrack.album || {}), + totalDiscs: track.album?.totalDiscs ?? totalDiscs, + numberOfTracksOnDisc: + track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber), + }; + } + } catch (e) { + console.warn('Failed to fetch album for disc info:', e); + } + } + blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); } } diff --git a/js/downloads.js b/js/downloads.js index ae947b2..bd05f19 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -103,6 +103,63 @@ async function createDiscLayoutContext(tracks, api) { return { separateByDisc: false, resolveDiscNumber: () => 1 }; } +async function computeDiscInfo(tracks, api = null) { + // First pass: collect explicit disc numbers from the raw track objects. + const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track)); + const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean)); + + let resolvedDiscNumbers = explicitDiscNumbers; + + // Some providers omit disc fields in the album payload. When we can't + // distinguish discs from the raw data and an API instance is provided, + // hydrate missing disc numbers via full-track metadata (mirrors the logic + // in createDiscLayoutContext). + if (explicitDistinct.size <= 1 && api) { + const hydratedDiscNumbers = await Promise.all( + tracks.map(async (track, index) => { + if (explicitDiscNumbers[index]) return explicitDiscNumbers[index]; + try { + const fullTrack = await api.getTrackMetadata(track.id); + return getExplicitTrackDiscNumber(fullTrack); + } catch { + return null; + } + }) + ); + const hydratedDistinct = new Set(hydratedDiscNumbers.filter(Boolean)); + if (hydratedDistinct.size > 1) { + resolvedDiscNumbers = hydratedDiscNumbers; + } + } + + const tracksPerDisc = new Map(); + let maxDiscNumber = 0; + for (let i = 0; i < tracks.length; i++) { + const discNumber = resolvedDiscNumbers[i] || 1; + tracksPerDisc.set(discNumber, (tracksPerDisc.get(discNumber) || 0) + 1); + if (discNumber > maxDiscNumber) { + maxDiscNumber = discNumber; + } + } + + return { totalDiscs: maxDiscNumber || 1, tracksPerDisc, resolvedDiscNumbers }; +} + +async function annotateTracksWithDiscInfo(tracks, api = null) { + const { totalDiscs, tracksPerDisc, resolvedDiscNumbers } = await computeDiscInfo(tracks, api); + return tracks.map((track, index) => { + const discNumber = resolvedDiscNumbers[index] || 1; + return { + ...track, + album: { + ...(track.album || {}), + totalDiscs, + numberOfTracksOnDisc: tracksPerDisc.get(discNumber), + }, + }; + }); +} + function getDiscFolderName(discNumber) { return `Disc ${discNumber}`; } @@ -321,15 +378,24 @@ async function downloadTrackBlob( // Non-fatal: continue with best available track payload } - if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) { + if (enrichedTrack.album?.id) { try { const albumData = await api.getAlbum(enrichedTrack.album.id); - if (albumData.album) { + if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) { enrichedTrack.album = { ...enrichedTrack.album, ...albumData.album, }; } + if (albumData.tracks?.length > 0) { + const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api); + const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1; + enrichedTrack.album = { + ...enrichedTrack.album, + totalDiscs, + numberOfTracksOnDisc: tracksPerDisc.get(discNumber), + }; + } } catch (error) { console.warn('Failed to fetch album data for metadata:', error); } @@ -1090,7 +1156,17 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana }); const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId); - await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'album', album.title, coverBlob, album); + await startBulkDownload( + await annotateTracksWithDiscInfo(tracks, api), + folderName, + api, + quality, + lyricsManager, + 'album', + album.title, + coverBlob, + album + ); } export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) { @@ -1132,7 +1208,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title); try { - const { album: fullAlbum, tracks } = await api.getAlbum(album.id); + const { album: fullAlbum, tracks: rawTracks } = await api.getAlbum(album.id); + const tracks = await annotateTracksWithDiscInfo(rawTracks, api); const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover); const releaseDateStr = fullAlbum.releaseDate || @@ -1303,7 +1380,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality if (signal.aborted) break; const album = selectedReleases[albumIndex]; updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title); - const { tracks } = await api.getAlbum(album.id); + const { tracks: rawTracks } = await api.getAlbum(album.id); + const tracks = await annotateTracksWithDiscInfo(rawTracks, api); await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification); } completeBulkDownload(notification, true); @@ -1447,15 +1525,24 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag // Continue with available track payload } - if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) { + if (enrichedTrack.album?.id) { try { const albumData = await api.getAlbum(enrichedTrack.album.id); - if (albumData.album) { + if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) { enrichedTrack.album = { ...enrichedTrack.album, ...albumData.album, }; } + if (albumData.tracks?.length > 0) { + const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api); + const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1; + enrichedTrack.album = { + ...enrichedTrack.album, + totalDiscs, + numberOfTracksOnDisc: tracksPerDisc.get(discNumber), + }; + } } catch (error) { console.warn('Failed to fetch album data for metadata:', error); } diff --git a/js/metadata.js b/js/metadata.js index 76e24b9..93c2e94 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -48,7 +48,8 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet data.albumArtist = track.album?.artist?.name || track.artist?.name; data.trackNumber = track.trackNumber; data.discNumber = track.volumeNumber ?? track.discNumber; - data.totalTracks = track.album.numberOfTracks; + data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks; + data.totalDiscs = track.album.totalDiscs; data.copyright = track.copyright; data.isrc = track.isrc; data.explicit = Boolean(track.explicit); From 2db782d74f72e1d7083253316d86277fd180635e Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:49:08 +0000 Subject: [PATCH 20/21] feat(downloads): add custom download formats --- index.html | 1 - js/api.js | 40 +++++++----- js/customFormats.ts | 148 ++++++++++++++++++++++++++++++++++++++++++++ js/downloads.js | 15 +++-- js/settings.js | 58 +++++++++++++++++ js/storage.js | 8 ++- js/utils.js | 20 ++++-- 7 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 js/customFormats.ts diff --git a/index.html b/index.html index 5a253c3..d4c5f9a 100644 --- a/index.html +++ b/index.html @@ -5108,7 +5108,6 @@ diff --git a/js/api.js b/js/api.js index 30c3d9f..3bdfbb6 100644 --- a/js/api.js +++ b/js/api.js @@ -11,9 +11,10 @@ import { APICache } from './cache.js'; import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { HlsDownloader } from './hls-downloader.js'; -import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js'; -import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; +import { MP3EncodingError } from './mp3-encoder.js'; +import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; +import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1294,8 +1295,8 @@ export class LosslessAPI { const isVideo = track?.type === 'video'; try { - // MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert - const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality; + // Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode + const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality; let lookup; if (isVideo) { @@ -1416,18 +1417,21 @@ export class LosslessAPI { } if (!isVideo) { - // Convert to MP3 320kbps if requested - if (quality === 'MP3_320') { - try { - blob = await encodeToMp3(blob, onProgress, options.signal); - } catch (encodingError) { - if (onProgress) { - onProgress({ - stage: 'error', - message: `Encoding failed: ${encodingError.message}`, - }); + // Transcode to custom format if requested + if (isCustomFormat(quality)) { + const format = getCustomFormat(quality); + if (format) { + try { + blob = await transcodeWithCustomFormat(blob, format, onProgress, options.signal); + } catch (encodingError) { + if (onProgress) { + onProgress({ + stage: 'error', + message: `Encoding failed: ${encodingError.message}`, + }); + } + throw encodingError; } - throw encodingError; } } @@ -1559,7 +1563,11 @@ export class LosslessAPI { throw error; } console.error('Download failed:', error); - if (error instanceof MP3EncodingError || error.code === 'MP3_ENCODING_FAILED') { + if ( + error instanceof MP3EncodingError || + error instanceof FfmpegError || + error.code === 'MP3_ENCODING_FAILED' + ) { throw error; } if (error.message === RATE_LIMIT_ERROR_MESSAGE) { diff --git a/js/customFormats.ts b/js/customFormats.ts new file mode 100644 index 0000000..5d9bb7c --- /dev/null +++ b/js/customFormats.ts @@ -0,0 +1,148 @@ +import { ffmpeg } from './ffmpeg'; + +export interface ProgressEvent { + stage?: string; + message?: string; + progress?: number; + receivedBytes?: number; + totalBytes?: number; +} + +export interface CustomFormat { + /** Human-readable label shown in the UI */ + displayName: string; + /** Internal identifier, must start with `FFMPEG_` */ + internalName: string; + /** Arguments passed to ffmpeg (excluding input/output file args) */ + ffmpegArgs: string[]; + /** Output filename used when calling ffmpeg */ + outputFilename: string; + /** MIME type of the encoded output */ + outputMime: string; + /** File extension of the encoded output */ + extension: string; + /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */ + category: string; +} + +export const customFormats: CustomFormat[] = [ + { + displayName: 'MP3 320kbps', + internalName: 'FFMPEG_MP3_320', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'MP3 256kbps', + internalName: 'FFMPEG_MP3_256', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'MP3 128kbps', + internalName: 'FFMPEG_MP3_128', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'OGG 320kbps', + internalName: 'FFMPEG_OGG_320', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '320k', + '-minrate', + '320k', + '-maxrate', + '320k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'OGG 256kbps', + internalName: 'FFMPEG_OGG_256', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '256k', + '-minrate', + '256k', + '-maxrate', + '256k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'OGG 128kbps', + internalName: 'FFMPEG_OGG_128', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '128k', + '-minrate', + '128k', + '-maxrate', + '128k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'AAC 256kbps', + internalName: 'FFMPEG_AAC_256', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'], + outputFilename: 'output.m4a', + outputMime: 'audio/mp4', + extension: 'm4a', + category: 'AAC', + }, +]; + +/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ +export function isCustomFormat(quality: string): boolean { + return getCustomFormat(quality) !== undefined; +} + +/** Looks up a custom format by its internal name, or returns undefined */ +export function getCustomFormat(internalName: string): CustomFormat | undefined { + return customFormats.find((f) => f.internalName === internalName); +} + +/** + * Transcodes an audio blob using the specified custom format via ffmpeg. + * Throws if ffmpeg fails during transcoding. + */ +export async function transcodeWithCustomFormat( + audioBlob: Blob, + format: CustomFormat, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); +} diff --git a/js/downloads.js b/js/downloads.js index bd05f19..718fba6 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -16,8 +16,8 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { DashDownloader } from './dash-downloader.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; -import { encodeToMp3 } from './mp3-encoder.js'; import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; +import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -355,8 +355,8 @@ async function downloadTrackBlob( artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null), }; - // MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert - const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality; + // Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode + const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality; try { const fullTrack = await api.getTrackMetadata(track.id); @@ -445,9 +445,12 @@ async function downloadTrackBlob( blob = await response.blob(); } - // Convert to MP3 320kbps if requested - if (quality === 'MP3_320') { - blob = await encodeToMp3(blob, onProgress || (() => undefined), signal); + // Transcode to custom format if requested + if (isCustomFormat(quality)) { + const format = getCustomFormat(quality); + if (format) { + blob = await transcodeWithCustomFormat(blob, format, onProgress || (() => undefined), signal); + } } if (quality.endsWith('LOSSLESS')) { diff --git a/js/settings.js b/js/settings.js index 3182372..d18d5b9 100644 --- a/js/settings.js +++ b/js/settings.js @@ -42,6 +42,7 @@ import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js'; +import { customFormats } from './customFormats.ts'; export function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab @@ -800,6 +801,63 @@ export function initializeSettings(scrobbler, player, api, ui) { // Download Quality setting const downloadQualitySetting = document.getElementById('download-quality-setting'); if (downloadQualitySetting) { + // Assign categories to the static (native) options already in the HTML + const staticCategories = { + HI_RES_LOSSLESS: 'Lossless', + LOSSLESS: 'Lossless', + HIGH: 'AAC', + LOW: 'AAC', + }; + + // Collect static options first (preserving their original order) + const allOptions = Array.from(downloadQualitySetting.options).map((opt) => ({ + value: opt.value, + text: opt.textContent, + category: staticCategories[opt.value] || 'Other', + })); + + // Append custom (ffmpeg-transcoded) format options + for (const fmt of customFormats) { + allOptions.push({ value: fmt.internalName, text: fmt.displayName, category: fmt.category }); + } + + // Sort by category order first, then by bitrate descending within each category + // so higher-quality options always appear before lower-quality ones. + // Options without an explicit kbps value (lossless) use Infinity so they + // sort to the top; ties fall back to display-name descending. + const getBitrate = (text) => { + const m = text.match(/(\d+)\s*kbps/i); + return m ? parseInt(m[1], 10) : Infinity; + }; + const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG']; + allOptions.sort((a, b) => { + const ai = categoryOrder.indexOf(a.category); + const bi = categoryOrder.indexOf(b.category); + const categoryDiff = (ai === -1 ? categoryOrder.length : ai) - (bi === -1 ? categoryOrder.length : bi); + if (categoryDiff !== 0) return categoryDiff; + const bitrateA = getBitrate(a.text); + const bitrateB = getBitrate(b.text); + if (bitrateA !== bitrateB) return bitrateB - bitrateA; + return b.text.localeCompare(a.text); + }); + + // Rebuild the select with optgroup elements per category + downloadQualitySetting.innerHTML = ''; + let currentGroup = null; + let currentCategory = null; + for (const opt of allOptions) { + if (opt.category !== currentCategory) { + currentCategory = opt.category; + currentGroup = document.createElement('optgroup'); + currentGroup.label = opt.category; + downloadQualitySetting.appendChild(currentGroup); + } + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.text; + currentGroup.appendChild(option); + } + downloadQualitySetting.value = downloadQualitySettings.getQuality(); downloadQualitySetting.addEventListener('change', (e) => { diff --git a/js/storage.js b/js/storage.js index dc1cbc8..de7c470 100644 --- a/js/storage.js +++ b/js/storage.js @@ -539,7 +539,13 @@ export const downloadQualitySettings = { STORAGE_KEY: 'download-quality', getQuality() { try { - return localStorage.getItem(this.STORAGE_KEY) || 'HI_RES_LOSSLESS'; + const stored = localStorage.getItem(this.STORAGE_KEY) || 'HI_RES_LOSSLESS'; + // Migrate legacy value to renamed format + if (stored === 'MP3_320') { + this.setQuality('FFMPEG_MP3_320'); + return 'FFMPEG_MP3_320'; + } + return stored; } catch { return 'HI_RES_LOSSLESS'; } diff --git a/js/utils.js b/js/utils.js index 3b5bf8f..728ddb3 100644 --- a/js/utils.js +++ b/js/utils.js @@ -108,6 +108,17 @@ export const detectAudioFormat = (view, mimeType = '') => { return 'flac'; } + // Check for OGG signature: "OggS" (0x4F 0x67 0x67 0x53) + if ( + view.byteLength >= 4 && + view.getUint8(0) === 0x4f && // O + view.getUint8(1) === 0x67 && // g + view.getUint8(2) === 0x67 && // g + view.getUint8(3) === 0x53 // S + ) { + return 'ogg'; + } + // Check for MP4/M4A signature: "ftyp" at offset 4 if ( view.byteLength >= 8 && @@ -153,6 +164,7 @@ export const detectAudioFormat = (view, mimeType = '') => { // Fallback to MIME type if (mimeType === 'audio/flac') return 'flac'; + if (mimeType === 'audio/ogg') return 'ogg'; if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4'; if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3'; @@ -177,8 +189,10 @@ export const getExtensionFromBlob = async (blob) => { if (format) return format; if (blob.type.includes('video')) return 'mp4'; - if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'm4a'; - if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') return 'mp3'; + if (mimeType === 'audio/flac') return 'flac'; + if (mimeType === 'audio/ogg') return 'ogg'; + if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4'; + if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3'; return 'flac'; }; @@ -188,8 +202,6 @@ export const getExtensionForQuality = (quality) => { case 'LOW': case 'HIGH': return 'm4a'; - case 'MP3_320': - return 'mp3'; default: return 'flac'; } From 7448ddce1eef81b092dbfa5020b5d099c6caa226 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:13:35 +0000 Subject: [PATCH 21/21] feat(downloads): add FLAC - Max Compression option and refactor transcoding logic --- index.html | 6 +- js/api.js | 41 ++++---- js/customFormats.ts | 161 +++---------------------------- js/downloads.js | 44 +++------ js/ffmpegFormats.ts | 229 ++++++++++++++++++++++++++++++++++++++++++++ js/settings.js | 9 +- 6 files changed, 282 insertions(+), 208 deletions(-) create mode 100644 js/ffmpegFormats.ts diff --git a/index.html b/index.html index d4c5f9a..e837995 100644 --- a/index.html +++ b/index.html @@ -5117,11 +5117,7 @@ Lossless Container Container format for lossless downloads

- +
diff --git a/js/api.js b/js/api.js index 3bdfbb6..cf71f83 100644 --- a/js/api.js +++ b/js/api.js @@ -12,9 +12,15 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { HlsDownloader } from './hls-downloader.js'; import { MP3EncodingError } from './mp3-encoder.js'; -import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; -import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; +import { + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1437,33 +1443,18 @@ export class LosslessAPI { if (quality.endsWith('LOSSLESS')) { try { - switch (losslessContainerSettings.getContainer()) { - case 'flac': - if ((await getExtensionFromBlob(blob)) != 'flac') { - blob = await ffmpeg( - blob, - { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, - 'output.flac', - 'audio/flac', - onProgress, - options.signal - ); - } else { - blob = await rebuildFlacWithoutMetadata(blob); - } - break; - case 'alac': - blob = await ffmpeg( + const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); + if (containerFmt) { + if (await containerFmt.needsTranscode(blob)) { + blob = await transcodeWithContainerFormat( blob, - { args: ['-c:a', 'alac'] }, - 'output.m4a', - 'audio/mp4', + containerFmt, onProgress, options.signal ); - break; - default: - break; + } else if ((await getExtensionFromBlob(blob)) == 'flac') { + blob = await rebuildFlacWithoutMetadata(blob); + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/customFormats.ts b/js/customFormats.ts index 5d9bb7c..f8d5c2e 100644 --- a/js/customFormats.ts +++ b/js/customFormats.ts @@ -1,148 +1,13 @@ -import { ffmpeg } from './ffmpeg'; - -export interface ProgressEvent { - stage?: string; - message?: string; - progress?: number; - receivedBytes?: number; - totalBytes?: number; -} - -export interface CustomFormat { - /** Human-readable label shown in the UI */ - displayName: string; - /** Internal identifier, must start with `FFMPEG_` */ - internalName: string; - /** Arguments passed to ffmpeg (excluding input/output file args) */ - ffmpegArgs: string[]; - /** Output filename used when calling ffmpeg */ - outputFilename: string; - /** MIME type of the encoded output */ - outputMime: string; - /** File extension of the encoded output */ - extension: string; - /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */ - category: string; -} - -export const customFormats: CustomFormat[] = [ - { - displayName: 'MP3 320kbps', - internalName: 'FFMPEG_MP3_320', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'], - outputFilename: 'output.mp3', - outputMime: 'audio/mpeg', - extension: 'mp3', - category: 'MP3', - }, - { - displayName: 'MP3 256kbps', - internalName: 'FFMPEG_MP3_256', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'], - outputFilename: 'output.mp3', - outputMime: 'audio/mpeg', - extension: 'mp3', - category: 'MP3', - }, - { - displayName: 'MP3 128kbps', - internalName: 'FFMPEG_MP3_128', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'], - outputFilename: 'output.mp3', - outputMime: 'audio/mpeg', - extension: 'mp3', - category: 'MP3', - }, - { - displayName: 'OGG 320kbps', - internalName: 'FFMPEG_OGG_320', - ffmpegArgs: [ - '-map_metadata', - '-1', - '-c:a', - 'libvorbis', - '-b:a', - '320k', - '-minrate', - '320k', - '-maxrate', - '320k', - ], - outputFilename: 'output.ogg', - outputMime: 'audio/ogg', - extension: 'ogg', - category: 'OGG', - }, - { - displayName: 'OGG 256kbps', - internalName: 'FFMPEG_OGG_256', - ffmpegArgs: [ - '-map_metadata', - '-1', - '-c:a', - 'libvorbis', - '-b:a', - '256k', - '-minrate', - '256k', - '-maxrate', - '256k', - ], - outputFilename: 'output.ogg', - outputMime: 'audio/ogg', - extension: 'ogg', - category: 'OGG', - }, - { - displayName: 'OGG 128kbps', - internalName: 'FFMPEG_OGG_128', - ffmpegArgs: [ - '-map_metadata', - '-1', - '-c:a', - 'libvorbis', - '-b:a', - '128k', - '-minrate', - '128k', - '-maxrate', - '128k', - ], - outputFilename: 'output.ogg', - outputMime: 'audio/ogg', - extension: 'ogg', - category: 'OGG', - }, - { - displayName: 'AAC 256kbps', - internalName: 'FFMPEG_AAC_256', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'], - outputFilename: 'output.m4a', - outputMime: 'audio/mp4', - extension: 'm4a', - category: 'AAC', - }, -]; - -/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ -export function isCustomFormat(quality: string): boolean { - return getCustomFormat(quality) !== undefined; -} - -/** Looks up a custom format by its internal name, or returns undefined */ -export function getCustomFormat(internalName: string): CustomFormat | undefined { - return customFormats.find((f) => f.internalName === internalName); -} - -/** - * Transcodes an audio blob using the specified custom format via ffmpeg. - * Throws if ffmpeg fails during transcoding. - */ -export async function transcodeWithCustomFormat( - audioBlob: Blob, - format: CustomFormat, - onProgress: ((progress: ProgressEvent) => void) | null = null, - signal: AbortSignal | null = null -): Promise { - return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); -} +// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts +export { + type ProgressEvent, + type CustomFormat, + type ContainerFormat, + customFormats, + containerFormats, + isCustomFormat, + getCustomFormat, + getContainerFormat, + transcodeWithCustomFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats'; diff --git a/js/downloads.js b/js/downloads.js index 718fba6..a8f629a 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -16,8 +16,14 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { DashDownloader } from './dash-downloader.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; -import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; -import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; +import { loadFfmpeg } from './ffmpeg.js'; +import { + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -455,33 +461,13 @@ async function downloadTrackBlob( if (quality.endsWith('LOSSLESS')) { try { - switch (losslessContainerSettings.getContainer()) { - case 'flac': - if ((await getExtensionFromBlob(blob)) != 'flac') { - blob = await ffmpeg( - blob, - { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, - 'output.flac', - 'audio/flac', - onProgress, - signal - ); - } else { - blob = await rebuildFlacWithoutMetadata(blob); - } - break; - case 'alac': - blob = await ffmpeg( - blob, - { args: ['-c:a', 'alac'] }, - 'output.m4a', - 'audio/mp4', - onProgress, - signal - ); - break; - default: - break; + const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); + if (containerFmt) { + if (await containerFmt.needsTranscode(blob)) { + blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); + } else if ((await getExtensionFromBlob(blob)) == 'flac') { + blob = await rebuildFlacWithoutMetadata(blob); + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts new file mode 100644 index 0000000..5e5a9cb --- /dev/null +++ b/js/ffmpegFormats.ts @@ -0,0 +1,229 @@ +import { ffmpeg } from './ffmpeg'; +import { getExtensionFromBlob } from './utils'; + +export interface ProgressEvent { + stage?: string; + message?: string; + progress?: number; + receivedBytes?: number; + totalBytes?: number; +} + +export interface CustomFormat { + /** Human-readable label shown in the UI */ + displayName: string; + /** Internal identifier, must start with `FFMPEG_` */ + internalName: string; + /** Arguments passed to ffmpeg (excluding input/output file args) */ + ffmpegArgs: string[]; + /** Output filename used when calling ffmpeg */ + outputFilename: string; + /** MIME type of the encoded output */ + outputMime: string; + /** File extension of the encoded output */ + extension: string; + /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */ + category: string; +} + +/** + * A container format definition for lossless re-muxing/re-encoding. + * Extends CustomFormat with a callback that decides whether ffmpeg needs to run + * at all (e.g. FLAC can skip if the source is already FLAC). + */ +export interface ContainerFormat extends Omit { + /** + * Returns true when the source blob must be passed through ffmpeg to produce + * the desired container. Return false to skip the ffmpeg step (the caller + * may still apply a lightweight metadata-strip pass instead). + */ + needsTranscode: (blob: Blob) => Promise; +} + +export const customFormats: CustomFormat[] = [ + { + displayName: 'MP3 320kbps', + internalName: 'FFMPEG_MP3_320', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'MP3 256kbps', + internalName: 'FFMPEG_MP3_256', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'MP3 128kbps', + internalName: 'FFMPEG_MP3_128', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'OGG 320kbps', + internalName: 'FFMPEG_OGG_320', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '320k', + '-minrate', + '320k', + '-maxrate', + '320k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'OGG 256kbps', + internalName: 'FFMPEG_OGG_256', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '256k', + '-minrate', + '256k', + '-maxrate', + '256k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'OGG 128kbps', + internalName: 'FFMPEG_OGG_128', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '128k', + '-minrate', + '128k', + '-maxrate', + '128k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'AAC 256kbps', + internalName: 'FFMPEG_AAC_256', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'], + outputFilename: 'output.m4a', + outputMime: 'audio/mp4', + extension: 'm4a', + category: 'AAC', + }, +]; + +/** + * Container format definitions for lossless re-muxing. Each entry describes + * the ffmpeg arguments needed to produce that container and provides a + * `needsTranscode` predicate so callers can skip the ffmpeg step when the + * source is already in the correct container. + */ +export const containerFormats: ContainerFormat[] = [ + { + displayName: 'FLAC', + internalName: 'flac', + ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], + outputFilename: 'output.flac', + outputMime: 'audio/flac', + extension: 'flac', + // Only transcode when the source is NOT already a FLAC file. + needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac', + }, + { + displayName: 'FLAC - Max Compression', + internalName: 'flac_max', + // `-compression_level 12` is the highest FLAC compression level; audio + // data is bit-identical to the source — only the compressed size changes. + ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], + outputFilename: 'output.flac', + outputMime: 'audio/flac', + extension: 'flac', + needsTranscode: async () => true, + }, + { + displayName: 'Apple Lossless', + internalName: 'alac', + ffmpegArgs: ['-c:a', 'alac'], + outputFilename: 'output.m4a', + outputMime: 'audio/mp4', + extension: 'm4a', + needsTranscode: async () => true, + }, + { + displayName: "Don't change", + internalName: 'nochange', + ffmpegArgs: [], + outputFilename: '', + outputMime: '', + extension: '', + needsTranscode: async () => false, + }, +]; + +/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ +export function isCustomFormat(quality: string): boolean { + return getCustomFormat(quality) !== undefined; +} + +/** Looks up a custom format by its internal name, or returns undefined */ +export function getCustomFormat(internalName: string): CustomFormat | undefined { + return customFormats.find((f) => f.internalName === internalName); +} + +/** Looks up a container format by its internal name, or returns undefined */ +export function getContainerFormat(internalName: string): ContainerFormat | undefined { + return containerFormats.find((f) => f.internalName === internalName); +} + +/** + * Transcodes an audio blob using the specified custom format via ffmpeg. + * Throws if ffmpeg fails during transcoding. + */ +export async function transcodeWithCustomFormat( + audioBlob: Blob, + format: CustomFormat, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); +} + +/** + * Re-muxes / re-encodes an audio blob into the specified container format via ffmpeg. + * Throws if ffmpeg fails during transcoding. + */ +export async function transcodeWithContainerFormat( + audioBlob: Blob, + format: ContainerFormat, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); +} diff --git a/js/settings.js b/js/settings.js index d18d5b9..da9cd81 100644 --- a/js/settings.js +++ b/js/settings.js @@ -42,7 +42,7 @@ import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js'; -import { customFormats } from './customFormats.ts'; +import { containerFormats, customFormats } from './ffmpegFormats.ts'; export function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab @@ -867,6 +867,13 @@ export function initializeSettings(scrobbler, player, api, ui) { const losslessContainerSetting = document.getElementById('lossless-container-setting'); if (losslessContainerSetting) { + for (const { internalName, displayName } of containerFormats) { + const option = document.createElement('option'); + option.value = internalName; + option.textContent = displayName; + losslessContainerSetting.appendChild(option); + } + losslessContainerSetting.value = losslessContainerSettings.getContainer(); losslessContainerSetting.addEventListener('change', (e) => {