diff --git a/js/api.js b/js/api.js
index 8972969..cf71f83 100644
--- a/js/api.js
+++ b/js/api.js
@@ -11,9 +11,16 @@ 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 { loadFfmpeg, FfmpegError } from './ffmpeg.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@@ -1097,13 +1104,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 +1113,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 [];
@@ -1291,8 +1301,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) {
@@ -1413,50 +1423,38 @@ 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;
}
}
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') {
@@ -1486,6 +1484,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);
}
}
@@ -1507,7 +1554,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..f8d5c2e
--- /dev/null
+++ b/js/customFormats.ts
@@ -0,0 +1,13 @@
+// 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 9f2dbbe..524148a 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -17,8 +17,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 { encodeToMp3 } from './mp3-encoder.js';
-import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
+import { loadFfmpeg } from './ffmpeg.js';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@@ -74,6 +80,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}`;
}
@@ -265,8 +328,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);
@@ -288,15 +351,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);
}
@@ -346,40 +418,23 @@ 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')) {
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') {
@@ -1082,7 +1137,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) {
@@ -1124,7 +1189,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 ||
@@ -1286,7 +1352,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);
@@ -1430,15 +1497,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/events.js b/js/events.js
index 03d0820..29dbc81 100644
--- a/js/events.js
+++ b/js/events.js
@@ -202,7 +202,20 @@ 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);
}
@@ -384,6 +397,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/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/metadata.js b/js/metadata.js
index d8bfb61..0de3d2b 100644
--- a/js/metadata.js
+++ b/js/metadata.js
@@ -55,7 +55,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 = getTrackDiscNumber(track) || undefined;
- 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);
diff --git a/js/player.js b/js/player.js
index 3701eff..6f52b71 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 =
@@ -580,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) {
@@ -895,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) {
@@ -984,13 +1007,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 +1046,40 @@ export class Player {
this.radioSeeds = await this.pickRadioSeeds();
}
+ const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random());
const seeds =
- this.radioSeeds.length > 0 ? this.radioSeeds : this.currentTrack ? [this.currentTrack] : [];
+ 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 +1136,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/settings.js b/js/settings.js
index c52cb0c..da9cd81 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 { containerFormats, customFormats } from './ffmpegFormats.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) => {
@@ -809,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) => {
@@ -2274,6 +2339,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();
});
}
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/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/ui-interactions.js b/js/ui-interactions.js
index 95cd7bc..59f2e06 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,223 @@ 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 +466,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);
diff --git a/js/ui.js b/js/ui.js
index 99d0b55..52c2d85 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();
@@ -2188,9 +2202,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');
diff --git a/js/utils.js b/js/utils.js
index b9790e0..e8050d0 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';
}
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/public/editors-picks.json b/public/editors-picks.json
index ce10ed3..808fa86 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"] }
}
]
diff --git a/styles.css b/styles.css
index af9949e..8ca624d 100644
--- a/styles.css
+++ b/styles.css
@@ -7590,7 +7590,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);