feat(downloads): add check box for atmos, and fallback to desired quality if unavailable
This commit is contained in:
parent
6e517fcb9b
commit
9c34dd4b9d
4 changed files with 68 additions and 11 deletions
11
index.html
11
index.html
|
|
@ -4166,7 +4166,6 @@
|
||||||
<span class="description">Quality for track downloads</span>
|
<span class="description">Quality for track downloads</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="download-quality-setting">
|
<select id="download-quality-setting">
|
||||||
<option value="DOLBY_ATMOS">Dolby Atmos (MP4)</option>
|
|
||||||
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
|
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
|
||||||
<option value="LOSSLESS">Lossless (16-bit)</option>
|
<option value="LOSSLESS">Lossless (16-bit)</option>
|
||||||
<option value="HIGH">AAC 320kbps</option>
|
<option value="HIGH">AAC 320kbps</option>
|
||||||
|
|
@ -4182,6 +4181,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Dolby Atmos</span>
|
||||||
|
<span class="description">Prefer Dolby Atmos tracks when available</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="dolby-atmos-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Lossless Container</span>
|
<span class="label">Lossless Container</span>
|
||||||
|
|
|
||||||
29
js/api.js
29
js/api.js
|
|
@ -10,7 +10,7 @@ import {
|
||||||
getTrackDiscNumber,
|
getTrackDiscNumber,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { trackDateSettings } from './storage.js';
|
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { DashDownloader } from './dash-downloader.ts';
|
import { DashDownloader } from './dash-downloader.ts';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
|
|
@ -1467,7 +1467,7 @@ export class LosslessAPI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS') {
|
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS', download = false) {
|
||||||
const cacheKey = `stream_info_${id}_${quality}`;
|
const cacheKey = `stream_info_${id}_${quality}`;
|
||||||
|
|
||||||
if (this.streamCache.has(cacheKey)) {
|
if (this.streamCache.has(cacheKey)) {
|
||||||
|
|
@ -1514,7 +1514,7 @@ export class LosslessAPI {
|
||||||
paramsArray.push(['formats', 'AACLC']);
|
paramsArray.push(['formats', 'AACLC']);
|
||||||
paramsArray.push(['formats', 'FLAC_HIRES']);
|
paramsArray.push(['formats', 'FLAC_HIRES']);
|
||||||
paramsArray.push(['formats', 'FLAC']);
|
paramsArray.push(['formats', 'FLAC']);
|
||||||
} else if (quality === 'DOLBY_ATMOS' && canPlayAtmos) {
|
} else if (quality === 'DOLBY_ATMOS' && (canPlayAtmos || download)) {
|
||||||
paramsArray.push(['formats', 'EAC3_JOC']);
|
paramsArray.push(['formats', 'EAC3_JOC']);
|
||||||
} else {
|
} else {
|
||||||
// Default fallback or "auto" behavior
|
// Default fallback or "auto" behavior
|
||||||
|
|
@ -1522,7 +1522,7 @@ export class LosslessAPI {
|
||||||
paramsArray.push(['formats', 'AACLC']);
|
paramsArray.push(['formats', 'AACLC']);
|
||||||
paramsArray.push(['formats', 'FLAC']);
|
paramsArray.push(['formats', 'FLAC']);
|
||||||
paramsArray.push(['formats', 'FLAC_HIRES']);
|
paramsArray.push(['formats', 'FLAC_HIRES']);
|
||||||
if (canPlayAtmos) {
|
if (canPlayAtmos || download) {
|
||||||
paramsArray.push(['formats', 'EAC3_JOC']);
|
paramsArray.push(['formats', 'EAC3_JOC']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1635,6 +1635,10 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
|
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
|
||||||
|
if (downloadQuality == 'DOLBY_ATMOS' && !input?.audioModes?.includes('DOLBY_ATMOS')) {
|
||||||
|
downloadQuality = 'LOSSLESS';
|
||||||
|
}
|
||||||
|
|
||||||
const id = input?.id || input;
|
const id = input?.id || input;
|
||||||
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
|
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
|
||||||
const isVideo = track?.type?.toLowerCase().includes('video');
|
const isVideo = track?.type?.toLowerCase().includes('video');
|
||||||
|
|
@ -1725,7 +1729,7 @@ export class LosslessAPI {
|
||||||
*
|
*
|
||||||
* @throws {Error} If stream URL cannot be resolved, manifest is missing, or download fails
|
* @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 {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 = {}) {
|
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
||||||
// Load ffmpeg in the background.
|
// Load ffmpeg in the background.
|
||||||
|
|
@ -1738,7 +1742,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
// 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 });
|
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
|
||||||
|
|
||||||
|
|
@ -1768,11 +1772,24 @@ export class LosslessAPI {
|
||||||
throw new Error('Could not resolve manifest');
|
throw new Error('Could not resolve 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);
|
||||||
|
const manifestText = await manifest.text();
|
||||||
|
streamUrl = this.extractStreamUrlFromManifest(btoa(manifestText));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to extract Dolby Atmos stream URL:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
throw new Error('Could not resolve stream URL');
|
throw new Error('Could not resolve stream URL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle DASH streams (blob URLs)
|
// Handle DASH streams (blob URLs)
|
||||||
if (streamUrl.startsWith('blob:')) {
|
if (streamUrl.startsWith('blob:')) {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
gaplessPlaybackSettings,
|
gaplessPlaybackSettings,
|
||||||
analyticsSettings,
|
analyticsSettings,
|
||||||
modalSettings,
|
modalSettings,
|
||||||
|
preferDolbyAtmosSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
|
|
@ -830,7 +831,6 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
if (downloadQualitySetting) {
|
if (downloadQualitySetting) {
|
||||||
// Assign categories to the static (native) options already in the HTML
|
// Assign categories to the static (native) options already in the HTML
|
||||||
const staticCategories = {
|
const staticCategories = {
|
||||||
DOLBY_ATMOS: 'Spatial',
|
|
||||||
HI_RES_LOSSLESS: 'Lossless',
|
HI_RES_LOSSLESS: 'Lossless',
|
||||||
LOSSLESS: 'Lossless',
|
LOSSLESS: 'Lossless',
|
||||||
HIGH: 'AAC',
|
HIGH: 'AAC',
|
||||||
|
|
@ -857,7 +857,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const m = text.match(/(\d+)\s*kbps/i);
|
const m = text.match(/(\d+)\s*kbps/i);
|
||||||
return m ? parseInt(m[1], 10) : Infinity;
|
return m ? parseInt(m[1], 10) : Infinity;
|
||||||
};
|
};
|
||||||
const categoryOrder = ['Spatial', 'Lossless', 'AAC', 'MP3', 'OGG'];
|
const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
|
||||||
allOptions.sort((a, b) => {
|
allOptions.sort((a, b) => {
|
||||||
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
|
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
|
||||||
const ai = categoryOrder.indexOf(a.category);
|
const ai = categoryOrder.indexOf(a.category);
|
||||||
|
|
@ -895,6 +895,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 losslessContainerSetting = document.getElementById('lossless-container-setting');
|
||||||
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
|
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,14 @@ export const downloadQualitySettings = {
|
||||||
this.setQuality('FFMPEG_MP3_320');
|
this.setQuality('FFMPEG_MP3_320');
|
||||||
return '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;
|
return stored;
|
||||||
} catch {
|
} catch {
|
||||||
return 'HI_RES_LOSSLESS';
|
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 = {
|
export const losslessContainerSettings = {
|
||||||
STORAGE_KEY: 'lossless-container',
|
STORAGE_KEY: 'lossless-container',
|
||||||
getContainer() {
|
getContainer() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue