diff --git a/index.html b/index.html index 85885cd..028d856 100644 --- a/index.html +++ b/index.html @@ -4075,6 +4075,49 @@
+
+
+
+
+ Download Quality + Quality for track downloads +
+ +
+ +
+
+
+ Dolby Atmos + Prefer Dolby Atmos tracks when available +
+ +
+
+
+ Lossless Container + Container format for lossless downloads +
+ +
+
@@ -4135,6 +4178,8 @@
+
+
Download Lyrics @@ -4159,38 +4204,6 @@
-
-
-
- Download Quality - Quality for track downloads -
- -
- -
-
-
- Lossless Container - Container format for lossless downloads -
- -
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() {