Cover Art Size
diff --git a/js/api.js b/js/api.js
index 68552bc..c50495d 100644
--- a/js/api.js
+++ b/js/api.js
@@ -10,11 +10,10 @@ import {
getTrackDiscNumber,
getMimeType,
} from './utils.js';
-import { trackDateSettings } from './storage.js';
+import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
import { APICache } from './cache.js';
import { DashDownloader } from './dash-downloader.ts';
import { HlsDownloader } from './hls-downloader.js';
-import { MP3EncodingError } from './mp3-encoder.js';
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
import { isCustomFormat } from './ffmpegFormats.ts';
@@ -31,6 +30,8 @@ import {
PlaybackInfo,
Track,
Album,
+ PreparedVideo,
+ PreparedTrack,
} from './container-classes.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
@@ -215,7 +216,11 @@ export class LosslessAPI {
if (track.type && typeof track.type === 'string') {
const lowType = track.type.toLowerCase();
- if (lowType.includes('video') || lowType.includes('track')) {
+ if (lowType.includes('video')) {
+ normalized = { ...track, type: 'video' };
+ } else if (lowType.includes('track')) {
+ normalized = { ...track, type: 'track' };
+ } else {
normalized = { ...track, type: lowType };
}
}
@@ -231,7 +236,7 @@ export class LosslessAPI {
normalized.isUnavailable = isTrackUnavailable(normalized);
- return normalized;
+ return normalized.type == 'video' ? new PreparedVideo(normalized) : new PreparedTrack(normalized);
}
prepareAlbum(album) {
@@ -639,7 +644,6 @@ export class LosslessAPI {
const response = await this.fetchWithRetry(`/video/?id=${id}`, {
type: 'streaming',
- allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
});
const jsonResponse = await response.json();
@@ -782,13 +786,13 @@ export class LosslessAPI {
tracks = tracks.map((t) => {
if (t.album) {
- t.album = Object.assign(new TrackAlbum(), t.album);
+ t.album = new TrackAlbum(t.album);
}
- return Object.assign(new Track(), t);
+ return new Track(t);
});
- album = Object.assign(new Album(), album);
+ album = new Album(album);
const result = { album, tracks };
@@ -904,10 +908,10 @@ export class LosslessAPI {
tracks = tracks.map((t) => {
if (t.album) {
- t.album = Object.assign(new TrackAlbum(), t.album);
+ t.album = new TrackAlbum(t.album);
}
- return Object.assign(new Track(), t);
+ return new Track(t);
});
const result = { playlist, tracks };
@@ -940,10 +944,10 @@ export class LosslessAPI {
tracks = tracks.map((t) => {
if (t.album) {
- t.album = Object.assign(new TrackAlbum(), t.album);
+ t.album = new TrackAlbum(t.album);
}
- return Object.assign(new Track(), t);
+ return new Track(t);
});
const mix = {
@@ -1468,7 +1472,7 @@ export class LosslessAPI {
return result;
}
- async getStreamUrl(id, quality = 'HI_RES_LOSSLESS') {
+ async getStreamUrl(id, quality = 'HI_RES_LOSSLESS', download = false) {
const cacheKey = `stream_info_${id}_${quality}`;
if (this.streamCache.has(cacheKey)) {
@@ -1515,7 +1519,7 @@ export class LosslessAPI {
paramsArray.push(['formats', 'AACLC']);
paramsArray.push(['formats', 'FLAC_HIRES']);
paramsArray.push(['formats', 'FLAC']);
- } else if (quality === 'DOLBY_ATMOS' && canPlayAtmos) {
+ } else if (quality === 'DOLBY_ATMOS' && (canPlayAtmos || download)) {
paramsArray.push(['formats', 'EAC3_JOC']);
} else {
// Default fallback or "auto" behavior
@@ -1523,7 +1527,7 @@ export class LosslessAPI {
paramsArray.push(['formats', 'AACLC']);
paramsArray.push(['formats', 'FLAC']);
paramsArray.push(['formats', 'FLAC_HIRES']);
- if (canPlayAtmos) {
+ if (canPlayAtmos || download) {
paramsArray.push(['formats', 'EAC3_JOC']);
}
}
@@ -1636,6 +1640,10 @@ export class LosslessAPI {
}
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
+ if (downloadQuality == 'DOLBY_ATMOS' && !input?.audioModes?.includes('DOLBY_ATMOS')) {
+ downloadQuality = 'LOSSLESS';
+ }
+
const id = input?.id || input;
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
const isVideo = track?.type?.toLowerCase().includes('video');
@@ -1645,7 +1653,7 @@ export class LosslessAPI {
if (isVideo) {
lookup = await this.getVideo(id);
} else {
- lookup = Object.assign(new PlaybackInfo(), await this.getTrack(id, downloadQuality));
+ lookup = new PlaybackInfo(await this.getTrack(id, downloadQuality));
}
if (input instanceof EnrichedTrack) {
@@ -1656,9 +1664,9 @@ export class LosslessAPI {
};
}
- const enrichedTrack = { ...track };
+ const enrichedTrack = { ...this.prepareTrack(track) };
if (lookup.info) {
- enrichedTrack.replayGain = Object.assign(new ReplayGain(), {
+ enrichedTrack.replayGain = new ReplayGain({
trackReplayGain: lookup.info.trackReplayGain,
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
albumReplayGain: lookup.info.albumReplayGain,
@@ -1669,7 +1677,7 @@ export class LosslessAPI {
if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
try {
const albumData = await this.getAlbum(track.album.id);
- enrichedTrack.album = Object.assign(new EnrichedAlbum(), {
+ enrichedTrack.album = new EnrichedAlbum({
...albumData.album,
...enrichedTrack.album,
});
@@ -1684,7 +1692,7 @@ export class LosslessAPI {
}
const totalDiscs = maxDiscNumber || 1;
const discNumber = getTrackDiscNumber(track);
- enrichedTrack.album = Object.assign(new EnrichedAlbum(), {
+ enrichedTrack.album = new EnrichedAlbum({
...(enrichedTrack.album || {}),
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
@@ -1697,10 +1705,10 @@ export class LosslessAPI {
}
if (!(enrichedTrack.album instanceof EnrichedAlbum)) {
- enrichedTrack.album = Object.assign(new TrackAlbum(), enrichedTrack.album);
+ enrichedTrack.album = new TrackAlbum(enrichedTrack.album);
}
- return { lookup, enrichedTrack: Object.assign(new EnrichedTrack(), enrichedTrack), isVideo };
+ return { lookup, enrichedTrack: new EnrichedTrack(enrichedTrack), isVideo };
}
/**
@@ -1726,7 +1734,7 @@ export class LosslessAPI {
*
* @throws {Error} If stream URL cannot be resolved, manifest is missing, or download fails
* @throws {AbortError} If the download is aborted via the signal
- * @throws {MP3EncodingError|FfmpegError} If audio transcoding fails
+ * @throws {FfmpegError} If audio transcoding fails
*/
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
// Load ffmpeg in the background.
@@ -1739,7 +1747,7 @@ export class LosslessAPI {
try {
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
- const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
+ let downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
@@ -1769,9 +1777,22 @@ export class LosslessAPI {
throw new Error('Could not resolve manifest');
}
- streamUrl = this.extractStreamUrlFromManifest(manifest);
+ if (preferDolbyAtmosSettings.isEnabled() && track.audioModes?.includes('DOLBY_ATMOS')) {
+ try {
+ const stream = await this.getStreamUrl(id, 'DOLBY_ATMOS', true);
+ const manifest = await fetch(stream.url, { signal: options.signal });
+ const manifestText = await manifest.text();
+ streamUrl = this.extractStreamUrlFromManifest(btoa(manifestText));
+ } catch (err) {
+ console.error('Failed to extract Dolby Atmos stream URL:', err);
+ }
+ }
+
if (!streamUrl) {
- throw new Error('Could not resolve stream URL');
+ streamUrl = this.extractStreamUrlFromManifest(manifest);
+ if (!streamUrl) {
+ throw new Error('Could not resolve stream URL');
+ }
}
}
@@ -1877,7 +1898,15 @@ export class LosslessAPI {
try {
if (isVideo) {
blob = new File(
- [await ffmpeg(blob, ['-c', 'copy'], 'output.mp4', 'video/mp4', onProgress, options.signal)],
+ [
+ await ffmpeg(blob, {
+ args: ['-c', 'copy'],
+ outputName: 'output.mp4',
+ outputMime: 'video/mp4',
+ onProgress,
+ signal: options.signal,
+ }),
+ ],
'output.mp4',
{ type: 'video/mp4' }
);
@@ -1908,11 +1937,7 @@ export class LosslessAPI {
throw error;
}
console.error('Download failed:', error);
- if (
- error instanceof MP3EncodingError ||
- error instanceof FfmpegError ||
- error.code === 'MP3_ENCODING_FAILED'
- ) {
+ if (error instanceof FfmpegError || error.code === 'MP3_ENCODING_FAILED') {
throw error;
}
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
diff --git a/js/app.js b/js/app.js
index 68025df..05be313 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1215,8 +1215,8 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
const { mix, tracks } = await MusicAPI.instance.getMix(mixId);
- const { downloadPlaylistAsZip } = await loadDownloadsModule();
- await downloadPlaylistAsZip(
+ const { downloadPlaylist } = await loadDownloadsModule();
+ await downloadPlaylist(
mix,
tracks,
MusicAPI.instance,
@@ -1264,8 +1264,8 @@ document.addEventListener('DOMContentLoaded', async () => {
tracks = data.tracks;
}
- const { downloadPlaylistAsZip } = await loadDownloadsModule();
- await downloadPlaylistAsZip(
+ const { downloadPlaylist } = await loadDownloadsModule();
+ await downloadPlaylist(
playlist,
tracks,
MusicAPI.instance,
@@ -2181,8 +2181,8 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
const { album, tracks } = await MusicAPI.instance.getAlbum(albumId);
- const { downloadAlbumAsZip } = await loadDownloadsModule();
- await downloadAlbumAsZip(
+ const { downloadAlbum } = await loadDownloadsModule();
+ await downloadAlbum(
album,
tracks,
MusicAPI.instance,
diff --git a/js/container-classes.ts b/js/container-classes.ts
index e33029b..57b3df9 100644
--- a/js/container-classes.ts
+++ b/js/container-classes.ts
@@ -1,11 +1,22 @@
-export class ReplayGain {
+export class BaseContainer {
+ constructor(data: T) {
+ Object.assign(this, data);
+ }
+}
+
+export class ReplayGain extends BaseContainer {
trackReplayGain: number;
albumReplayGain: number;
trackPeakAmplitude: number;
albumPeakAmplitude: number;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
-export class Track {
+export class Track extends BaseContainer {
accessType: string;
adSupportedStreamReady: boolean;
album: TrackAlbum;
@@ -40,6 +51,11 @@ export class Track {
url: string;
version?: string;
volumeNumber: number;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
export class PlaybackInfo extends ReplayGain {
@@ -52,31 +68,56 @@ export class PlaybackInfo extends ReplayGain {
manifest: string;
bitDepth: number;
sampleRate: number;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
-export class MediaMetadata {
+export class MediaMetadata extends BaseContainer {
tags: string[];
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
-export class Artist {
+export class Artist extends BaseContainer {
handle: any;
id: number;
name: string;
picture: string;
type: string;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
export class EnrichedTrack extends Track {
declare album: TrackAlbum | EnrichedAlbum;
declare replayGain: any | ReplayGain;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
-export class TrackAlbum {
+export class TrackAlbum extends BaseContainer {
cover: string;
id: number;
title: string;
vibrantColor: string;
videoCover?: string;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
export class Album extends TrackAlbum {
@@ -105,9 +146,48 @@ export class Album extends TrackAlbum {
upload: boolean;
url: string;
version?: string;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
export class EnrichedAlbum extends Album {
totalDiscs?: number;
numberOfTracksOnDisc?: number;
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
+}
+
+export class PreparedItem extends BaseContainer {
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
+}
+export class PreparedTrack extends PreparedItem {
+ type: 'track';
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
+}
+export class PreparedAlbum extends PreparedItem {
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
+}
+export class PreparedVideo extends PreparedItem {
+ type: 'video';
+
+ constructor(data: object) {
+ super(data);
+ Object.assign(this, data);
+ }
}
diff --git a/js/download-utils.ts b/js/download-utils.ts
index 51ed90c..04f9ad2 100644
--- a/js/download-utils.ts
+++ b/js/download-utils.ts
@@ -9,7 +9,7 @@ import {
getContainerFormat,
transcodeWithContainerFormat,
} from './ffmpegFormats';
-import { ffmpegNewContainer } from './ffmpeg';
+import { ffmpegInfo, ffmpegNewContainer } from './ffmpeg';
/**
* Triggers a browser file download for the given blob.
@@ -72,15 +72,22 @@ export async function applyAudioPostProcessing(
trackAudioQuality: string | null = null
): Promise {
const extension = await getExtensionFromBlob(blob);
+ const statedLossless = (trackAudioQuality || quality).endsWith('LOSSLESS');
// Determine whether the downloaded source is lossless.
// FLAC is always lossless. m4a is lossless only when the track's
// audio quality from the API is LOSSLESS or HI_RES_LOSSLESS; otherwise
// it is AAC (lossy).
- const sourceIsLossless =
+ let sourceIsLossless =
extension === 'flac' ||
(extension === 'm4a' && (trackAudioQuality === 'LOSSLESS' || trackAudioQuality === 'HI_RES_LOSSLESS'));
+ if (statedLossless && !sourceIsLossless) {
+ // Basic checks say the file isn't lossless, but we'll use ffmpegInfo to check the codec.
+ const ffmpegLog: string[] = await ffmpegInfo(blob);
+ sourceIsLossless = ffmpegLog.some((line) => line.match(/Stream #\d:\d -> #\d:\d \(flac/));
+ }
+
// Transcode to custom lossy format if requested
if (isCustomFormat(quality)) {
// If the source is already lossy, transcoding would degrade quality
@@ -104,7 +111,7 @@ export async function applyAudioPostProcessing(
}
}
- if (quality.endsWith('LOSSLESS')) {
+ if (statedLossless) {
try {
const containerName = losslessContainerSettings.getContainer();
const containerFmt = getContainerFormat(containerName);
diff --git a/js/downloads.js b/js/downloads.js
index c0e231f..ed548af 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -682,7 +682,7 @@ export async function downloadTracks(tracks, api, quality, lyricsManager = null)
});
}
-export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
+export async function downloadAlbum(album, tracks, api, quality, lyricsManager = null) {
const releaseDateStr =
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
@@ -707,7 +707,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
});
}
-export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
+export async function downloadPlaylist(playlist, tracks, api, quality, lyricsManager = null) {
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
albumTitle: playlist.title,
albumArtist: 'Playlist',
@@ -939,11 +939,32 @@ function createBulkDownloadNotification(type, name, _totalItems) {
return notifEl;
}
+/**
+ *
+ * @param {HTMLElement} notifEl
+ * @param {number} current
+ * @param {number} total
+ * @param {string} currentItem
+ * @param {FfmpegProgress | ProgressMessage | null} progress
+ * @returns
+ */
function updateBulkDownloadProgress(notifEl, current, total, currentItem, progress = null) {
+ /** @type {HTMLElement | null} */
const progressFill = notifEl.querySelector('.download-progress-fill');
+
+ /** @type {HTMLElement | null} */
const statusEl = notifEl.querySelector('.download-status');
+ if (!progressFill || !statusEl) {
+ console.log('Progress elements not found in notification');
+ return;
+ }
+
if (progress instanceof FfmpegProgress) {
+ if (progress.stage == 'stdout') {
+ return;
+ }
+
const percent = progress.progress || 0;
progressFill.style.width = `${percent}%`;
progressFill.style.background = '#3b82f6'; // Blue for encoding
diff --git a/js/events.js b/js/events.js
index e90f7cf..f069c2c 100644
--- a/js/events.js
+++ b/js/events.js
@@ -15,7 +15,7 @@ import {
waveformSettings,
keyboardShortcuts,
} from './storage.js';
-import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
+import { showNotification, downloadTrackWithMetadata, downloadAlbum, downloadPlaylist } from './downloads.js';
import { downloadQualitySettings } from './storage.js';
import { updateTabTitle, navigate } from './router.js';
import { db } from './db.js';
@@ -1238,7 +1238,7 @@ export async function handleTrackAction(
if (action === 'download') {
if (type === 'album') {
- await downloadAlbumAsZip(
+ await downloadAlbum(
collectionItem,
tracks,
api,
@@ -1246,7 +1246,7 @@ export async function handleTrackAction(
lyricsManager
);
} else {
- await downloadPlaylistAsZip(
+ await downloadPlaylist(
collectionItem,
tracks,
api,
diff --git a/js/ffmpeg.js b/js/ffmpeg.js
index fff10f0..f5e5f40 100644
--- a/js/ffmpeg.js
+++ b/js/ffmpeg.js
@@ -38,6 +38,7 @@ export function loadFfmpeg() {
* @param {(progress: FfmpegProgress) => void} onProgress
* @param {AbortSignal|null} signal
* @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles
+ * @param {Boolean} logConsole - Whether to log FFmpeg output to the console
* @returns {Promise} Encoded audio blob
*/
async function ffmpegWorker(
@@ -47,7 +48,8 @@ async function ffmpegWorker(
outputMime = 'application/octet-stream',
onProgress = null,
signal = null,
- extraFiles = []
+ extraFiles = [],
+ logConsole = true
) {
const audioData = audioBlob ? await audioBlob.arrayBuffer() : null;
const assets = loadFfmpeg();
@@ -85,7 +87,10 @@ async function ffmpegWorker(
} else if (type === 'progress' && stage != 'loading' && progress !== null) {
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
} else if (type === 'log') {
- console.log('[FFmpeg]', message);
+ onProgress?.(new FfmpegProgress('stdout', 0, message));
+ if (logConsole) {
+ console.log('[FFmpeg]', message);
+ }
}
};
@@ -127,29 +132,43 @@ async function ffmpegWorker(
* Encodes audio using FFmpeg via Web Worker
* @async
* @param {Blob} audioBlob - The audio blob to encode
- * @param {string[]} [args=[]] - FFmpeg command-line arguments
- * @param {string} [outputName='output'] - Name of the output file
- * @param {string} [outputMime='application/octet-stream'] - MIME type of the output
- * @param {(progress: FfmpegProgress) => void} [onProgress=null] - Optional callback for progress updates
- * @param {AbortSignal|null} [signal=null] - Optional abort signal to cancel encoding
- * @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
+ * @param {Object} [opts] - Options for FFmpeg encoding
+ * @param {string[]} [opts.args=[]] - FFmpeg command-line arguments
+ * @param {string} [opts.outputName='output'] - Name of the output file
+ * @param {string} [opts.outputMime='application/octet-stream'] - MIME type of the output
+ * @param {(progress: FfmpegProgress) => void} [opts.onProgress=null] - Optional callback for progress updates
+ * @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding
+ * @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg
+ * @param {Boolean} [opts.logConsole=true] - Whether to log FFmpeg output to the console
* @returns {Promise} Encoded audio blob
* @throws {FfmpegError} If Web Workers are not available
* @throws {Error} If FFmpeg encoding fails
*/
export async function ffmpeg(
audioBlob,
- args = [],
- outputName = 'output',
- outputMime = 'application/octet-stream',
- onProgress = null,
- signal = null,
- extraFiles = []
+ {
+ args = [],
+ outputName = 'output',
+ outputMime = 'application/octet-stream',
+ onProgress = null,
+ signal = null,
+ extraFiles = [],
+ logConsole = true,
+ } = {}
) {
try {
// Use Web Worker for non-blocking FFmpeg encoding
if (typeof Worker !== 'undefined') {
- return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal, extraFiles);
+ return await ffmpegWorker(
+ audioBlob,
+ args,
+ outputName,
+ outputMime,
+ onProgress,
+ signal,
+ extraFiles,
+ logConsole
+ );
}
throw new FfmpegError('Web Workers are required for FFMPEG');
@@ -159,24 +178,56 @@ export async function ffmpeg(
}
}
+/**
+ * Retrieves information about an audio blob using FFmpeg
+ * @param {Blob} audioBlob - The audio blob to analyze
+ * @param {Object} [options] - Options for FFmpeg info extraction
+ * @param {((progress: FfmpegProgress) => void) | null} [options.onProgress] - Callback function to track conversion progress
+ * @param {AbortSignal|null} [options.signal] - AbortSignal for cancelling the operation
+ * @returns {Promise} A promise that resolves to an array of output lines
+ */
+export async function ffmpegInfo(audioBlob, { onProgress = null, signal = null } = {}) {
+ const outputLines = [];
+ try {
+ await ffmpeg(audioBlob, {
+ args: ['-t', '0.01'],
+ outputName: 'output.wav',
+ onProgress: (progress) => {
+ if (progress.stage === 'stdout' && progress.message) {
+ outputLines.push(progress.message);
+ }
+
+ onProgress?.(progress);
+ },
+ signal,
+ logConsole: false,
+ });
+ } catch (err) {
+ if (err instanceof FfmpegError && !err.message.startsWith('Failed to delete')) {
+ console.warn('FFmpeg info extraction failed:', err);
+ }
+ }
+
+ return outputLines;
+}
+
/**
* Creates a new FFmpeg container with copied codec and stripped metadata.
* @param {Blob} audioBlob - The audio blob to process
* @param {string} outputExtension - The extension for the output file
* @param {string} outputMime - The MIME type for the output blob
- * @param {Function} onProgress - Callback function to track conversion progress
+ * @param {((progress: FfmpegProgress) => void) | null} onProgress - Callback function to track conversion progress
* @param {AbortSignal} signal - AbortSignal for cancelling the operation
* @returns {Promise} A promise that resolves to the processed data blob
*/
export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) {
- return await ffmpeg(
- audioBlob,
- ['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
- `output.${outputExtension}`,
- outputMime,
+ return await ffmpeg(audioBlob, {
+ args: ['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
+ outputName: `output.${outputExtension}`,
+ outputMime: outputMime,
onProgress,
- signal
- );
+ signal: signal,
+ });
}
export { FfmpegError };
diff --git a/js/ffmpeg.types.ts b/js/ffmpeg.types.ts
index 43ffa38..2eb82dd 100644
--- a/js/ffmpeg.types.ts
+++ b/js/ffmpeg.types.ts
@@ -1,6 +1,6 @@
export class FfmpegProgress implements MonochromeProgress {
constructor(
- public readonly stage: 'loading' | 'encoding' | 'finalizing',
+ public readonly stage: 'loading' | 'encoding' | 'finalizing' | 'stdout',
public readonly progress: number,
public readonly message?: string
) {}
diff --git a/js/ffmpeg.worker.js b/js/ffmpeg.worker.js
index e331ec6..b34c4ce 100644
--- a/js/ffmpeg.worker.js
+++ b/js/ffmpeg.worker.js
@@ -123,7 +123,7 @@ self.onmessage = async (e) => {
await ffmpeg.writeFile(file.name, new Uint8Array(file.data));
}
- const ffmpegArgs = ['-i', 'input', ...args, output.name];
+ const ffmpegArgs = ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
self.postMessage({ type: 'log', message: `FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}` });
const exitCode = await ffmpeg.exec(ffmpegArgs);
@@ -134,7 +134,7 @@ self.onmessage = async (e) => {
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 });
- const data = await ffmpeg.readFile(output.name);
+ const data = output.name ? await ffmpeg.readFile(output.name) : [];
const outputBlob = new Blob([data], { type: output.mime });
self.postMessage({ type: 'complete', blob: outputBlob });
@@ -152,7 +152,9 @@ self.onmessage = async (e) => {
}
}
try {
- await ffmpeg.deleteFile(output.name);
+ if (output.name) {
+ await ffmpeg.deleteFile(output.name);
+ }
} catch {
self.postMessage({ type: 'log', message: `Failed to delete ${output.name} from FFmpeg FS.` });
}
diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts
index 562cbd0..eeb27a1 100644
--- a/js/ffmpegFormats.ts
+++ b/js/ffmpegFormats.ts
@@ -194,15 +194,14 @@ export async function transcodeWithCustomFormat(
signal: AbortSignal | null = null,
extraFiles: any[] = []
): Promise {
- return ffmpeg(
- audioBlob,
- format.ffmpegArgs,
- format.outputFilename,
- format.outputMime,
+ return ffmpeg(audioBlob, {
+ args: format.ffmpegArgs,
+ outputName: format.outputFilename,
+ outputMime: format.outputMime,
onProgress,
signal,
- extraFiles
- );
+ extraFiles,
+ });
}
/**
@@ -216,13 +215,12 @@ export async function transcodeWithContainerFormat(
signal: AbortSignal | null = null,
extraFiles: any[] = []
): Promise {
- return ffmpeg(
- audioBlob,
- format.ffmpegArgs,
- format.outputFilename,
- format.outputMime,
+ return ffmpeg(audioBlob, {
+ args: format.ffmpegArgs,
+ outputName: format.outputFilename,
+ outputMime: format.outputMime,
onProgress,
signal,
- extraFiles
- );
+ extraFiles,
+ });
}
diff --git a/js/mp3-encoder.js b/js/mp3-encoder.js
deleted file mode 100644
index f45811a..0000000
--- a/js/mp3-encoder.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { ffmpeg } from './ffmpeg';
-
-/**
- * @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
- */
-
-class MP3EncodingError extends Error {
- constructor(message) {
- super(message);
- this.name = 'MP3EncodingError';
- this.code = 'MP3_ENCODING_FAILED';
- }
-}
-
-/**
- *
- * @param {Blob} audioBlob
- * @param {(progress: FfmpegProgress) => void} [onProgress=null]
- * @param {AbortSignal|null} [signal=null]
- * @returns {Promise} Encoded MP3 audio blob
- */
-export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
- try {
- // Use Web Worker for non-blocking FFmpeg encoding
- if (typeof Worker !== 'undefined') {
- const args = ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'];
-
- return await ffmpeg(audioBlob, { args }, 'output.mp3', 'audio/mpeg', onProgress, signal);
- }
-
- throw new MP3EncodingError('Web Workers are required for MP3 encoding');
- } catch (error) {
- console.error('MP3 encoding failed:', error);
-
- throw new MP3EncodingError(error?.message ?? error);
- }
-}
-
-export { MP3EncodingError };
diff --git a/js/player.js b/js/player.js
index 91e43b4..de0b850 100644
--- a/js/player.js
+++ b/js/player.js
@@ -493,7 +493,10 @@ export class Player {
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue;
try {
- const streamInfo = await this.api.getStreamUrl(track.id, this.quality);
+ const streamInfo =
+ track.type == 'video'
+ ? await this.api.getVideoStreamUrl(track.id)
+ : await this.api.getStreamUrl(track.id, this.quality);
if (this.preloadAbortController.signal.aborted) break;
diff --git a/js/settings.js b/js/settings.js
index 5037b46..0ffe0b4 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -34,6 +34,7 @@ import {
gaplessPlaybackSettings,
analyticsSettings,
modalSettings,
+ preferDolbyAtmosSettings,
} from './storage.js';
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
import { db } from './db.js';
@@ -813,7 +814,6 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (downloadQualitySetting) {
// Assign categories to the static (native) options already in the HTML
const staticCategories = {
- DOLBY_ATMOS: 'Spatial',
HI_RES_LOSSLESS: 'Lossless',
LOSSLESS: 'Lossless',
HIGH: 'AAC',
@@ -840,7 +840,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const m = text.match(/(\d+)\s*kbps/i);
return m ? parseInt(m[1], 10) : Infinity;
};
- const categoryOrder = ['Spatial', 'Lossless', 'AAC', 'MP3', 'OGG'];
+ const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
allOptions.sort((a, b) => {
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
const ai = categoryOrder.indexOf(a.category);
@@ -878,6 +878,14 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
+ const prefersAtmosSetting = document.getElementById('dolby-atmos-toggle');
+ if (prefersAtmosSetting) {
+ prefersAtmosSetting.checked = preferDolbyAtmosSettings.isEnabled();
+ prefersAtmosSetting.addEventListener('change', (e) => {
+ preferDolbyAtmosSettings.setEnabled(e.target.checked);
+ });
+ }
+
const losslessContainerSetting = document.getElementById('lossless-container-setting');
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
@@ -979,7 +987,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (resetSavedFolderSetting) {
let showReset = false;
if (isFolderMethod && hasFolderPicker && modernSettings.rememberBulkDownloadFolder) {
- const savedHandle = await db.getSetting('bulk_download_folder_handle');
+ const savedHandle = modernSettings.bulkDownloadFolder;
showReset = !!savedHandle;
}
resetSavedFolderSetting.style.display = showReset ? '' : 'none';
@@ -1064,7 +1072,8 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (resetSavedFolderBtn) {
resetSavedFolderBtn.addEventListener('click', async () => {
- await db.saveSetting('bulk_download_folder_handle', null);
+ modernSettings.bulkDownloadFolder = null;
+ await modernSettings.waitPending();
await updateFolderMethodVisibility();
});
}
@@ -1084,7 +1093,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
}
updateForceZipBlobVisibility();
- updateFolderMethodVisibility();
+ await updateFolderMethodVisibility();
const includeCoverToggle = document.getElementById('include-cover-toggle');
if (includeCoverToggle) {
@@ -2581,6 +2590,23 @@ export async function initializeSettings(scrobbler, player, api, ui) {
observer.observe(appearanceTabContent, { attributes: true });
}
+ // Watch for downloads tab becoming active and update setting visibility
+ const downloadsTabContent = document.getElementById('settings-tab-downloads');
+ if (downloadsTabContent) {
+ const observer = new MutationObserver(async (mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
+ if (downloadsTabContent.classList.contains('active')) {
+ console.log('[Settings] Downloads tab became active, updating setting visibility');
+ updateForceZipBlobVisibility();
+ await updateFolderMethodVisibility();
+ }
+ }
+ }
+ });
+ observer.observe(downloadsTabContent, { attributes: true });
+ }
+
// Visualizer Mode Select
const visualizerModeSelect = document.getElementById('visualizer-mode-select');
if (visualizerModeSelect) {
diff --git a/js/storage.js b/js/storage.js
index 6f8a0a4..69780c8 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -648,6 +648,14 @@ export const downloadQualitySettings = {
this.setQuality('FFMPEG_MP3_320');
return 'FFMPEG_MP3_320';
}
+
+ // Migrate legacy atmos value
+ if (stored === 'DOLBY_ATMOS') {
+ this.setQuality('HI_RES_LOSSLESS');
+ preferDolbyAtmosSettings.setEnabled(true);
+ return 'HI_RES_LOSSLESS';
+ }
+
return stored;
} catch {
return 'HI_RES_LOSSLESS';
@@ -658,6 +666,21 @@ export const downloadQualitySettings = {
},
};
+export const preferDolbyAtmosSettings = {
+ STORAGE_KEY: 'prefer-dolby-atmos',
+ isEnabled() {
+ try {
+ const stored = localStorage.getItem(this.STORAGE_KEY) || 'false';
+ return stored === 'true';
+ } catch {
+ return false;
+ }
+ },
+ setEnabled(enabled) {
+ localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
+ },
+};
+
export const losslessContainerSettings = {
STORAGE_KEY: 'lossless-container',
getContainer() {